mMicrosoftLoginListener = (key, value) -> {
- mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color));
+ mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent));
new MicrosoftBackgroundLogin(false, value.getQueryParameter("code")).performLogin(
mProgressListener, mDoneListener, mErrorListener);
return false;
@@ -149,8 +153,8 @@ public mcAccountSpinner(@NonNull Context context, @Nullable AttributeSet attrs)
@SuppressLint("ClickableViewAccessibility")
private void init(){
// Set visual properties
- setBackgroundColor(getResources().getColor(R.color.background_status_bar));
- mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color));
+ setBackgroundColor(getThemeColor(net.kdt.pojavlaunch.R.attr.colorBgStatusBar));
+ mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent));
mLoginBarPaint.setStrokeWidth(getResources().getDimensionPixelOffset(R.dimen._2sdp));
// Set behavior
@@ -276,9 +280,13 @@ private void reloadAccounts(boolean fromFiles, int overridePosition){
}
private void performLogin(MinecraftAccount minecraftAccount){
+ // Logging in when there's no internet is useless. This should really be turned into a network callback though.
+ if(!Tools.isOnline(getContext())){
+ return;
+ }
if(minecraftAccount.isLocal()) return;
- mLoginBarPaint.setColor(getResources().getColor(R.color.minebutton_color));
+ mLoginBarPaint.setColor(getThemeColor(net.kdt.pojavlaunch.R.attr.copperAccent));
if(minecraftAccount.isMicrosoft){
if(System.currentTimeMillis() > minecraftAccount.expiresAt){
// Perform login only if needed
@@ -296,14 +304,23 @@ private void pickAccount(int position){
PojavProfile.setCurrentProfile(getContext(), mAccountList.get(position));
selectedAccount = PojavProfile.getCurrentProfileContent(getContext(), mAccountList.get(position));
-
// WORKAROUND
// Account file corrupted due to previous versions having improper encoding
if (selectedAccount == null){
- removeCurrentAccount();
- pickAccount(-1);
- setSelection(0);
- return;
+ Context ctx = Objects.requireNonNull(getContext());
+
+ new AlertDialog.Builder(ctx)
+ .setCancelable(false)
+ .setTitle(R.string.account_corrupted)
+ .setMessage(R.string.login_again)
+ .setPositiveButton(R.string.delete_account_and_login, (dialog, which) -> {
+ removeCurrentAccount();
+ pickAccount(-1);
+ setSelection(0);
+ })
+ .show();
+
+
}
setSelection(position);
}else {
@@ -405,6 +422,10 @@ private void showDeleteDialog(Context context, int position) {
}
}
-
-
-}
+ /** Resolve a theme colour attribute (e.g. R.attr.copperAccent) to an int colour. */
+ private int getThemeColor(int attr) {
+ TypedValue tv = new TypedValue();
+ getContext().getTheme().resolveAttribute(attr, tv, true);
+ return tv.data;
+ }
+}
\ No newline at end of file
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 50dfd75bac..a3fd5f599d 100644
--- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java
+++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcVersionSpinner.java
@@ -92,6 +92,19 @@ public void openProfileEditor(FragmentActivity fragmentActivity) {
/** Reload profiles from the file, forcing the spinner to consider the new data */
public void reloadProfiles(){
mProfileAdapter.reloadProfiles();
+ // Re-apply selection so the spinner redraws the icon at the correct size.
+ // Also handles deletion: REFRESH_VERSION_SPINNER extra is consumed here.
+ String extra_value = (String) ExtraCore.consumeValue(ExtraConstants.REFRESH_VERSION_SPINNER);
+ int newIndex;
+ if (extra_value != null) {
+ newIndex = extra_value.equals(DELETED_PROFILE) ? 0
+ : Math.max(0, getProfileAdapter().resolveProfileIndex(extra_value));
+ } else {
+ newIndex = Math.max(0, mProfileAdapter.resolveProfileIndex(
+ LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "")));
+ }
+ setProfileSelection(newIndex);
}
/** Initialize various behaviors */
@@ -198,4 +211,4 @@ private void hidePopup(boolean animate) {
public ProfileAdapter getProfileAdapter() {
return mProfileAdapter;
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java
index 8716525d47..1bacc785e9 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Architecture.java
@@ -103,5 +103,17 @@ public static String archAsString(int arch){
if(arch == ARCH_X86) return "x86";
return "UNSUPPORTED_ARCH";
}
+ /**
+ * Convert to a string an architecture.
+ * @param arch The architecture as an int.
+ * @return "arm64" || "arm" || "x86_64" || "x86" || "UNSUPPORTED_ARCH"
+ */
+ public static String archAsStringAndroid(int arch) {
+ if(arch == ARCH_ARM64) return "arm64-v8a";
+ if(arch == ARCH_ARM) return "armeabi-v7a";
+ if(arch == ARCH_X86_64) return "x86_64";
+ if(arch == ARCH_X86) return "x86";
+ return "UNSUPPORTED_ARCH";
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java
new file mode 100644
index 0000000000..85d3be99e3
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/CallbackBridge.java
@@ -0,0 +1,136 @@
+package net.kdt.pojavlaunch;
+
+import android.content.Intent;
+import android.net.Uri;
+import android.view.Choreographer;
+import android.view.KeyEvent;
+
+import androidx.annotation.Keep;
+
+import net.kdt.pojavlaunch.lifecycle.ContextExecutor;
+
+import java.io.File;
+
+import git.artdeell.dnbootstrap.glfw.GLFW;
+
+public class CallbackBridge {
+ public static final Choreographer sChoreographer = Choreographer.getInstance();
+
+ public static volatile int windowWidth, windowHeight;
+ public volatile static boolean holdingAlt, holdingCapslock, holdingCtrl,
+ holdingNumlock, holdingShift;
+
+ public static void performClick(int button) {
+ double ox = GLFW.cursorX, oy = GLFW.cursorY;
+ GLFW.sendMouseEvent(button, 1, CallbackBridge.getCurrentMods());
+ sChoreographer.postFrameCallbackDelayed(l -> {
+ GLFW.cursorX = ox;
+ GLFW.cursorY = oy;
+ GLFW.sendMouseEvent(button, 0, CallbackBridge.getCurrentMods());
+ }, 33);
+ }
+
+
+ public static void sendKeyPress(int keyCode) {
+ GLFW.sendKeyEvent(keyCode, true, getCurrentMods());
+ GLFW.sendKeyEvent(keyCode, false, getCurrentMods());
+ }
+
+ public static void sendMouseButton(int button, boolean status) {
+ CallbackBridge.sendMouseKeycode(button, CallbackBridge.getCurrentMods(), status);
+ }
+
+ public static void sendMouseKeycode(int button, int modifiers, boolean isDown) {
+ GLFW.sendMouseEvent(button, isDown ? 1 : 0, modifiers);
+ }
+
+ public static void sendScroll(double xoffset, double yoffset) {
+ GLFW.sendScrollEvent(xoffset, yoffset);
+ }
+
+ public static int getCurrentMods() {
+ int currMods = 0;
+ if (holdingAlt) {
+ currMods |= LwjglGlfwKeycode.GLFW_MOD_ALT;
+ } if (holdingCapslock) {
+ currMods |= LwjglGlfwKeycode.GLFW_MOD_CAPS_LOCK;
+ } if (holdingCtrl) {
+ currMods |= LwjglGlfwKeycode.GLFW_MOD_CONTROL;
+ } if (holdingNumlock) {
+ currMods |= LwjglGlfwKeycode.GLFW_MOD_NUM_LOCK;
+ } if (holdingShift) {
+ currMods |= LwjglGlfwKeycode.GLFW_MOD_SHIFT;
+ }
+ return currMods;
+ }
+
+ public static void setModifiers(KeyEvent keyEvent) {
+ CallbackBridge.holdingAlt = keyEvent.isAltPressed();
+ CallbackBridge.holdingCapslock = keyEvent.isCapsLockOn();
+ CallbackBridge.holdingCtrl = keyEvent.isCtrlPressed();
+ CallbackBridge.holdingNumlock = keyEvent.isNumLockOn();
+ CallbackBridge.holdingShift = keyEvent.isShiftPressed();
+ }
+
+ public static void setModifiers(int keyCode, boolean isDown){
+ switch (keyCode){
+ case LwjglGlfwKeycode.GLFW_KEY_LEFT_SHIFT:
+ CallbackBridge.holdingShift = isDown;
+ return;
+
+ case LwjglGlfwKeycode.GLFW_KEY_LEFT_CONTROL:
+ CallbackBridge.holdingCtrl = isDown;
+ return;
+
+ case LwjglGlfwKeycode.GLFW_KEY_LEFT_ALT:
+ CallbackBridge.holdingAlt = isDown;
+ return;
+
+ case LwjglGlfwKeycode.GLFW_KEY_CAPS_LOCK:
+ CallbackBridge.holdingCapslock = isDown;
+ return;
+
+ case LwjglGlfwKeycode.GLFW_KEY_NUM_LOCK:
+ CallbackBridge.holdingNumlock = isDown;
+ }
+ }
+
+ @Keep
+ public static void openLink(String link) {
+ ContextExecutor.executeActivity(ctx->{
+ try {
+ if(link.startsWith("file:")) {
+ int truncLength = 5;
+ if(link.startsWith("file://")) truncLength = 7;
+ String path = link.substring(truncLength);
+ Tools.openPath(ctx, new File(path), false);
+ }else {
+ Intent intent = new Intent(Intent.ACTION_VIEW);
+ intent.setDataAndType(Uri.parse(link), "*/*");
+ ctx.startActivity(intent);
+ }
+ } catch (Throwable th) {
+ Tools.showError(ctx, th);
+ }
+ });
+ }
+
+ @SuppressWarnings("unused") //TODO: actually use it
+ public static void openPath(String path) {
+ ContextExecutor.executeActivity(ctx->{
+ try {
+ Tools.openPath(ctx, new File(path), false);
+ } catch (Throwable th) {
+ Tools.showError(ctx, th);
+ }
+ });
+ }
+
+ public static native void minibridgeInit();
+
+ static {
+ System.loadLibrary("pojavexec");
+ minibridgeInit();
+ }
+}
+
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java
index 5fce0dcbc4..47af3319fd 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/EfficientAndroidLWJGLKeycode.java
@@ -15,6 +15,7 @@ public class EfficientAndroidLWJGLKeycode {
//The value its LWJGL equivalent.
private static final int KEYCODE_COUNT = 106;
private static final int[] sAndroidKeycodes = new int[KEYCODE_COUNT];
+ private static final int[] sLwjglKeycodesReversed = new int[LwjglGlfwKeycode.GLFW_KEY_LAST];
private static final short[] sLwjglKeycodes = new short[KEYCODE_COUNT];
private static String[] androidKeyNameArray; /* = new String[androidKeycodes.length]; */
private static int mTmpCount = 0;
@@ -198,6 +199,28 @@ public static void execKeyIndex(int index){
sendKeyPress(getValueByIndex(index));
}
+ /**
+ * Takes a GLFW keycode and returns its char primitive. Works with Shift/Caps Lock.
+ *
+ * Non-letter characters return U+0000.
+ *
+ * @param lwjglGlfwKeycode A GLFW key code macro (e.g., {@link LwjglGlfwKeycode#GLFW_KEY_W}).
+ */
+ public static char getLwjglChar(int lwjglGlfwKeycode){
+ int androidKeycode = sAndroidKeycodes[sLwjglKeycodesReversed[lwjglGlfwKeycode]];
+ KeyEvent key = new KeyEvent(KeyEvent.ACTION_UP, androidKeycode);
+ char charToSend;
+ charToSend = ((char) key.getUnicodeChar());
+ int currentMods = CallbackBridge.getCurrentMods();
+ if (Character.isLetter(charToSend) && (
+ ((currentMods & LwjglGlfwKeycode.GLFW_MOD_SHIFT) != 0) ^
+ ((currentMods & LwjglGlfwKeycode.GLFW_MOD_CAPS_LOCK) != 0))
+ ){
+ charToSend = Character.toUpperCase(charToSend);
+ }
+ return charToSend;
+ }
+
public static short getValueByIndex(int index) {
return sLwjglKeycodes[index];
}
@@ -218,6 +241,7 @@ public static int getIndexByValue(int lwjglKey) {
private static void add(int androidKeycode, short LWJGLKeycode){
sAndroidKeycodes[mTmpCount] = androidKeycode;
sLwjglKeycodes[mTmpCount] = LWJGLKeycode;
+ sLwjglKeycodesReversed[LWJGLKeycode] = mTmpCount;
mTmpCount ++;
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java
new file mode 100644
index 0000000000..51d4a89c4d
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/InsetBackground.java
@@ -0,0 +1,81 @@
+package net.kdt.pojavlaunch;
+
+import android.graphics.Canvas;
+import android.graphics.ColorFilter;
+import android.graphics.Insets;
+import android.graphics.Paint;
+import android.graphics.PixelFormat;
+import android.graphics.Rect;
+import android.graphics.drawable.Drawable;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RequiresApi;
+
+@RequiresApi(29)
+public class InsetBackground extends Drawable {
+ private final Rect mLeftRect = new Rect();
+ private final Rect mTopRect = new Rect();
+ private final Rect mRightRect = new Rect();
+ private final Rect mBottomRect = new Rect();
+ private final Paint mRectPaint = new Paint();
+ private final Insets mInsets;
+
+ public InsetBackground(Insets insets, int bgColor) {
+ Log.i("InsetBackground", insets.toString());
+ mInsets = insets;
+ mRectPaint.setColor(bgColor);
+ }
+
+ private void computeRects(int width, int height) {
+ mLeftRect.left = 0;
+ mLeftRect.right = mInsets.left;
+ mLeftRect.top = 0;
+ mLeftRect.bottom = height;
+
+ mTopRect.left = mInsets.left;
+ mTopRect.right = width - mInsets.right;
+ mTopRect.top = 0;
+ mTopRect.bottom = mInsets.top;
+
+ mRightRect.left = width - mInsets.right;
+ mRightRect.right = width;
+ mRightRect.top = 0;
+ mRightRect.bottom = height;
+
+ mBottomRect.left = 0;
+ mBottomRect.right = width;
+ mBottomRect.top = height - mInsets.bottom;
+ mBottomRect.bottom = height;
+ }
+
+ @Override
+ protected void onBoundsChange(@NonNull Rect bounds) {
+ computeRects(bounds.width(), bounds.height());
+ invalidateSelf();
+ }
+
+ @Override
+ public void draw(@NonNull Canvas canvas) {
+ canvas.drawRect(mLeftRect, mRectPaint);
+ canvas.drawRect(mRightRect, mRectPaint);
+ canvas.drawRect(mTopRect, mRectPaint);
+ canvas.drawRect(mBottomRect, mRectPaint);
+ }
+
+ @Override
+ public void setAlpha(int alpha) {
+
+ }
+
+ @Override
+ public void setColorFilter(@Nullable ColorFilter colorFilter) {
+
+ }
+
+ @Override
+ public int getOpacity() {
+ return PixelFormat.TRANSPARENT;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java
index 6db1ccbf2f..d96967a915 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/JavaGUILauncherActivity.java
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch;
+import static net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF;
+
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import android.content.ClipboardManager;
@@ -242,8 +244,8 @@ private void startModInstaller(File modFile, String javaArgs) {
selectedMod = findModPath(argList);
}
Runtime selectedRuntime;
- if(selectedMod == null) {
- // We were unable to find out the path to the mod. In that case, use the default runtime.
+ if(selectedMod == null || DEFAULT_PREF.getBoolean("disable_autojre_select", false)) {
+ // If we are unable to find out the path to the mod or the user explicitly desires so, we use the default runtime
selectedRuntime = MultiRTUtils.forceReread(LauncherPreferences.PREF_DEFAULT_RUNTIME);
}else {
// Autoselect it properly in the other case.
@@ -355,9 +357,8 @@ public void launchJavaRuntime(Runtime runtime, File modFile, List javaAr
JREUtils.redirectAndPrintJRELog();
try {
List javaArgList = new ArrayList<>();
-
// Enable Caciocavallo
- Tools.getCacioJavaArgs(javaArgList,runtime.javaVersion == 8);
+ Tools.getCacioJavaArgs(javaArgList,runtime.javaVersion == 8, this);
if(javaArgs != null) {
javaArgList.addAll(javaArgs);
}
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 44a873b47c..630f359f89 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java
@@ -1,12 +1,15 @@
package net.kdt.pojavlaunch;
import static android.content.res.Configuration.ORIENTATION_PORTRAIT;
+import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog;
+
import android.Manifest;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
+import android.util.Log;
import android.view.View;
import android.widget.ImageButton;
import android.widget.Toast;
@@ -34,6 +37,10 @@
import net.kdt.pojavlaunch.lifecycle.ContextAwareDoneListener;
import net.kdt.pojavlaunch.lifecycle.ContextExecutor;
import net.kdt.pojavlaunch.modloaders.modpacks.ModloaderInstallTracker;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModLoader;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackInstaller;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.NotificationDownloadListener;
import net.kdt.pojavlaunch.modloaders.modpacks.imagecache.IconCacheJanitor;
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import net.kdt.pojavlaunch.prefs.screens.LauncherPreferenceFragment;
@@ -43,11 +50,15 @@
import net.kdt.pojavlaunch.tasks.AsyncMinecraftDownloader;
import net.kdt.pojavlaunch.tasks.AsyncVersionList;
import net.kdt.pojavlaunch.tasks.MinecraftDownloader;
+import net.kdt.pojavlaunch.utils.DateUtils;
import net.kdt.pojavlaunch.utils.NotificationUtils;
import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+import java.io.IOException;
import java.lang.ref.WeakReference;
+import java.security.NoSuchAlgorithmException;
+import java.text.ParseException;
public class LauncherActivity extends BaseActivity {
public static final String SETTING_FRAGMENT_TAG = "SETTINGS_FRAGMENT";
@@ -56,6 +67,25 @@ public class LauncherActivity extends BaseActivity {
registerForActivityResult(new OpenDocumentWithExtension("jar"), (data)->{
if(data != null) Tools.launchModInstaller(this, data);
});
+ public final ActivityResultLauncher modpackImportLauncher =
+ registerForActivityResult(new OpenDocumentWithExtension(new String[]{"zip", "mrpack"}), (data)->{
+ if(data != null) {
+ PojavApplication.sExecutorService.execute(() -> {
+ try {
+ ModLoader loaderInfo = new CommonApi(getString(R.string.curseforge_api_key)).importModpack(this, data);
+ if (loaderInfo == null) return;
+ loaderInfo.getDownloadTask(new NotificationDownloadListener(this, loaderInfo)).run();
+ } catch (IOException e) {
+ Tools.showErrorRemote(this, R.string.modpack_install_download_failed, e);
+ } catch (IllegalArgumentException e) {
+ Tools.showError(this, R.string.not_modpack_file, e);
+ } catch (NoSuchAlgorithmException e) {
+ // Should literally never happen because SHA-1 is required Java spec
+ throw new RuntimeException(e);
+ }
+ });
+ }
+ });
private mcAccountSpinner mAccountSpinner;
private FragmentContainerView mFragmentView;
@@ -86,17 +116,30 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f)
// Allow starting the add account only from the main menu, should it be moved to fragment itself ?
if(!(fragment instanceof MainMenuFragment)) return false;
- Tools.swapFragment(this, SelectAuthFragment.class, SelectAuthFragment.TAG, null);
+ // In landscape two-pane mode, load into right pane; otherwise full-screen swap
+ MainMenuFragment mmf = (MainMenuFragment) fragment;
+ if (!mmf.tryOpenInRightPane(SelectAuthFragment.class, SelectAuthFragment.TAG, null)) {
+ Tools.swapFragment(this, SelectAuthFragment.class, SelectAuthFragment.TAG, null);
+ }
return false;
};
/* Listener for the settings fragment */
private final View.OnClickListener mSettingButtonListener = v -> {
Fragment fragment = getSupportFragmentManager().findFragmentById(mFragmentView.getId());
- if(fragment instanceof MainMenuFragment){
- Tools.swapFragment(this, LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null);
- } else{
- // The setting button doubles as a home button now
+ if (fragment instanceof MainMenuFragment) {
+ MainMenuFragment mmf = (MainMenuFragment) fragment;
+ // In two-pane landscape: if right pane already has content, pressing the
+ // gear/home button pops back to home. If pane is at home, open settings.
+ if (mmf.isRightPaneActive()) {
+ mmf.clearRightPane();
+ } else {
+ if (!mmf.tryOpenInRightPane(LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null)) {
+ Tools.swapFragment(this, LauncherPreferenceFragment.class, SETTING_FRAGMENT_TAG, null);
+ }
+ }
+ } else {
+ // Portrait: the settings button doubles as a home button when not on main menu
Tools.backToMainMenu(this);
}
};
@@ -125,6 +168,23 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f)
}
String normalizedVersionId = AsyncMinecraftDownloader.normalizeVersionId(prof.lastVersionId);
JMinecraftVersionList.Version mcVersion = AsyncMinecraftDownloader.getListedVersion(normalizedVersionId);
+
+ // Do not load when is a modded version or older than minecraft 1.3 on demo account
+ if (mAccountSpinner.getSelectedAccount().isDemo()) {
+ boolean isOlderThan13 = true;
+
+ if (mcVersion != null) {
+ try {
+ isOlderThan13 = DateUtils.dateBefore(DateUtils.parseReleaseDate(mcVersion.releaseTime), 2012, 6, 22);
+ } catch (ParseException ignored) {}
+ }
+
+ if (isOlderThan13) {
+ hasNoOnlineProfileDialog(this, getString(R.string.global_error), getString(R.string.demo_versions_supported));
+ return false;
+ }
+ }
+
new MinecraftDownloader().start(
this,
mcVersion,
@@ -145,7 +205,9 @@ public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f)
};
private ActivityResultLauncher mRequestNotificationPermissionLauncher;
+ private ActivityResultLauncher mRequestMicrophonePermissionLauncher;
private WeakReference mRequestNotificationPermissionRunnable;
+ private WeakReference mRequestMicrophonePermissionRunnable;
@Override
protected boolean shouldIgnoreNotch() {
@@ -160,6 +222,12 @@ public boolean setFullscreen() {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ // Apply saved colour theme before layout inflation
+ setTheme(net.kdt.pojavlaunch.theme.ThemeManager.getSavedTheme());
+ // Apply force-landscape preference before layout inflation
+ if (LauncherPreferences.DEFAULT_PREF.getBoolean("force_landscape", false)) {
+ setRequestedOrientation(android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
+ }
setContentView(R.layout.activity_pojav_launcher);
FragmentManager fragmentManager = getSupportFragmentManager();
// If we don't have a back stack root yet...
@@ -185,6 +253,16 @@ protected void onCreate(Bundle savedInstanceState) {
}
}
);
+ mRequestMicrophonePermissionLauncher = registerForActivityResult(
+ new ActivityResultContracts.RequestPermission(),
+ isAllowed -> {
+ if(!isAllowed) handleNoNotificationPermission();
+ else {
+ Runnable runnable = Tools.getWeakReference(mRequestMicrophonePermissionRunnable);
+ if(runnable != null) runnable.run();
+ }
+ }
+ );
getWindow().setBackgroundDrawable(null);
bindViews();
checkNotificationPermission();
@@ -254,6 +332,18 @@ public void onBackPressed() {
}
}
+ // In landscape two-pane mode: if the right pane has content, pop it instead of exiting
+ Fragment rootFrag = getVisibleFragment("ROOT");
+ if (rootFrag instanceof MainMenuFragment) {
+ MainMenuFragment mmf = (MainMenuFragment) rootFrag;
+ if (mmf.isRightPaneActive()) {
+ mmf.popRightPane();
+ return;
+ }
+ finish();
+ return;
+ }
+
// Check if we are at the root then
if(getVisibleFragment("ROOT") != null){
finish();
@@ -322,6 +412,11 @@ public boolean checkForNotificationPermission() {
this,
Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_DENIED;
}
+ public boolean checkForMicrophonePermission() {
+ return ContextCompat.checkSelfPermission(
+ this,
+ Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_DENIED;
+ }
public void askForNotificationPermission(Runnable onSuccessRunnable) {
if(Build.VERSION.SDK_INT < 33) return;
@@ -331,6 +426,13 @@ public void askForNotificationPermission(Runnable onSuccessRunnable) {
mRequestNotificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
}
+ public void askForMicrophonePermission(Runnable onSuccessRunnable) {
+ if(onSuccessRunnable != null) {
+ mRequestMicrophonePermissionRunnable = new WeakReference<>(onSuccessRunnable);
+ }
+ mRequestMicrophonePermissionLauncher.launch(Manifest.permission.RECORD_AUDIO);
+ }
+
/** Stuff all the view boilerplate here */
private void bindViews(){
mFragmentView = findViewById(R.id.container_fragment);
@@ -338,4 +440,4 @@ private void bindViews(){
mAccountSpinner = findViewById(R.id.account_spinner);
mProgressLayout = findViewById(R.id.progress_layout);
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java
index bcd5fb4776..d2e41f8744 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java
@@ -2,24 +2,52 @@
import androidx.annotation.Keep;
+import java.util.concurrent.CopyOnWriteArrayList;
+
/** Singleton class made to log on one file
* The singleton part can be removed but will require more implementation from the end-dev
*/
@Keep
public class Logger {
+ private static final CopyOnWriteArrayList logListeners = new CopyOnWriteArrayList<>();
+
/** Print the text to the log file if not censored */
public static native void appendToLog(String text);
-
/** Reset the log file, effectively erasing any previous logs */
public static native void begin(String logFilePath);
- /** Small listener for anything listening to the log */
+ /** Add a listener for the logfile, ask the native side for a listener if needed */
+ public static void addLogListener(eventLogListener logListeners) {
+ boolean wasEmpty = Logger.logListeners.isEmpty();
+ Logger.logListeners.add(logListeners);
+ if (wasEmpty) setLogListener(Logger::onEventLogged);
+ }
+
+ /** Remove a listener for the logfile, unset the native listener if no listeners left */
+ public static void removeLogListener(eventLogListener logListener) {
+ Logger.logListeners.remove(logListener);
+ if (Logger.logListeners.isEmpty()){
+ // Makes the JNI code be able to skip expensive logger callbacks
+ // NOTE: was tested by rapidly smashing the log on/off button, no sync issues found :)
+ setLogListener(null);
+ }
+ }
+
+ private static void onEventLogged(String text) {
+ for (eventLogListener logListener: Logger.logListeners) {
+ logListener.onEventLogged(text);
+ }
+ }
+
+ /** Small listener for anything listening to the log
+ * Performs double duty as being the interface for java listeners and the native callback
+ */
@Keep
public interface eventLogListener {
void onEventLogged(String text);
}
/** Link a log listener to the logger */
- public static native void setLogListener(eventLogListener logListener);
+ private static native void setLogListener(eventLogListener logListener);
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java
index 1ba8d6d4c9..7cfdb40713 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LwjglGlfwKeycode.java
@@ -197,6 +197,7 @@ public class LwjglGlfwKeycode {
GLFW_MOUSE_BUTTON_MIDDLE = GLFW_MOUSE_BUTTON_3;
public static final int
+ GLFW_FOCUSED = 0x20001,
GLFW_VISIBLE = 0x20004,
GLFW_HOVERED = 0x2000B;
}
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 42052ce201..996edf58a8 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java
@@ -1,6 +1,9 @@
package net.kdt.pojavlaunch;
import static net.kdt.pojavlaunch.Tools.currentDisplayMetrics;
+import static net.kdt.pojavlaunch.Tools.dialogForceClose;
+import static net.kdt.pojavlaunch.Tools.hasMods;
+import static net.kdt.pojavlaunch.Tools.runMethodbyReflection;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_ENABLE_GYRO;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_SUSTAINED_PERFORMANCE;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_USE_ALTERNATE_SURFACE;
@@ -24,13 +27,11 @@
import android.os.Build;
import android.os.Bundle;
import android.os.IBinder;
-import android.provider.DocumentsContract;
import android.util.Log;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
import android.view.View;
-import android.webkit.MimeTypeMap;
import android.widget.AdapterView;
import android.widget.ArrayAdapter;
import android.widget.ListView;
@@ -62,20 +63,26 @@
import net.kdt.pojavlaunch.services.GameService;
import net.kdt.pojavlaunch.utils.JREUtils;
import net.kdt.pojavlaunch.utils.MCOptionUtils;
+import net.kdt.pojavlaunch.utils.TouchControllerUtils;
import net.kdt.pojavlaunch.value.MinecraftAccount;
import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+import org.libsdl.app.SDL;
+import org.libsdl.app.SDLSurface;
import org.lwjgl.glfw.CallbackBridge;
import java.io.File;
import java.io.IOException;
+import java.util.Objects;
public class MainActivity extends BaseActivity implements ControlButtonMenuListener, EditorExitable, ServiceConnection {
public static volatile ClipboardManager GLOBAL_CLIPBOARD;
+ public static final String TAG = "MainActivity";
public static final String INTENT_MINECRAFT_VERSION = "intent_version";
volatile public static boolean isInputStackCall;
+ protected static View.OnGenericMotionListener motionListener = (v, event) -> false;
public static TouchCharInput touchCharInput;
private MinecraftGLSurface minecraftGLView;
@@ -101,8 +108,43 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
+ if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU) {
+ // TODO: Use lower level HID capture that needs a dialogue box from the user for the
+ // app to fully take focus of the input devices. Might cause issues with older android
+ // versions so we don't use that right now. Needs testing.
+ // Currently tried but only identification works OOTB, inputs aren't being sent.
+
+ // TODO: Use a hook to load SDL logic depending on whether libSDL3.so is loaded.
+ try {
+ // Note: This doesn't dlopen it for the mod, they still have to do it themselves
+ // Why? https://github.com/android/ndk/issues/201#issuecomment-248060092
+ // Just in case that gets deleted off the internet:
+ // "On Android only the main executable and LD_PRELOADs are considered to be
+ // RTLD_GLOBAL, all the dependencies of the main executable remain RTLD_LOCAL." - dimitry
+ SDL.loadLibrary("SDL3", this);
+ SDL.loadLibrary("SDL2", this);
+ SDL.initialize();
+ SDL.setupJNI();
+ SDL.setContext(this);
+ new SDLSurface(this);
+ motionListener = (View.OnGenericMotionListener)
+ runMethodbyReflection("org.libsdl.app.SDLActivity",
+ "getMotionListener");
+ if (LauncherPreferences.PREF_GAMEPAD_FORCEDSDL_PASSTHRU) Tools.SDL.initializeControllerSubsystems();
+ } catch (UnsatisfiedLinkError ignored) {
+ // Ignore because if SDL.setupJNI(); fails, SDL wasn't loaded.
+ } catch (ReflectiveOperationException e) {
+ Tools.showErrorRemote("SDL did not load properly.", e);
+ }
+ }
+
minecraftProfile = LauncherProfiles.getCurrentProfile();
- MCOptionUtils.load(Tools.getGameDirPath(minecraftProfile).getAbsolutePath());
+
+ String gameDirPath = Tools.getGameDirPath(minecraftProfile).getAbsolutePath();
+ MCOptionUtils.load(gameDirPath);
+ if (Tools.hasTouchController(new File(gameDirPath)) || LauncherPreferences.PREF_FORCE_ENABLE_TOUCHCONTROLLER) {
+ TouchControllerUtils.initialize(this);
+ }
Intent gameServiceIntent = new Intent(this, GameService.class);
// Start the service a bit early
@@ -167,6 +209,9 @@ protected void initLayout(int resId) {
if(minecraftProfile.pojavRendererName != null) {
Log.i("RdrDebug","__P_renderer="+minecraftProfile.pojavRendererName);
Tools.LOCAL_RENDERER = minecraftProfile.pojavRendererName;
+ // TODO: Remove this jank when it's not relevant anymore
+ // Shitty hack to make OSMZink smoothly transition into kopper
+ if (minecraftProfile.pojavRendererName.equals("vulkan_zink")) Tools.LOCAL_RENDERER = "opengles3_desktopgl_zink_kopper";
}
setTitle("Minecraft " + minecraftProfile.lastVersionId);
@@ -190,10 +235,11 @@ protected void initLayout(int resId) {
android.R.layout.simple_list_item_1, getResources().getStringArray(R.array.menu_ingame));
gameActionClickListener = (parent, view, position, id) -> {
switch(position) {
- case 0: openLogOutput(); break;
- case 1: dialogSendCustomKey(); break;
- case 2: openQuickSettings(); break;
- case 3: openCustomControls(); break;
+ case 0: dialogForceClose(MainActivity.this); break;
+ case 1: openLogOutput(); break;
+ case 2: dialogSendCustomKey(); break;
+ case 3: openQuickSettings(); break;
+ case 4: openCustomControls(); break;
}
drawerLayout.closeDrawers();
};
@@ -268,6 +314,7 @@ private void bindValues(){
public void onResume() {
super.onResume();
if(PREF_ENABLE_GYRO) mGyroControl.enable();
+ CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, 1);
CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_HOVERED, 1);
}
@@ -280,7 +327,9 @@ protected void onPause() {
if(mQuickSettingSideDialog != null) {
mQuickSettingSideDialog.cancel();
}
+ CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, 0);
CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_HOVERED, 0);
+
super.onPause();
}
@@ -307,7 +356,6 @@ protected void onDestroy() {
@Override
public void onConfigurationChanged(@NonNull Configuration newConfig) {
super.onConfigurationChanged(newConfig);
-
if(mGyroControl != null) mGyroControl.updateOrientation();
// Layout resize is practically guaranteed on a configuration change, and `onConfigurationChanged`
// does not implicitly start a layout. So, request a layout and expect the screen dimensions to be valid after the]
@@ -315,6 +363,7 @@ public void onConfigurationChanged(@NonNull Configuration newConfig) {
mControlLayout.requestLayout();
mControlLayout.post(()->{
// Child of mControlLayout, so refreshing size here is correct
+ Tools.setFullscreen(this, setFullscreen());
minecraftGLView.refreshSize();
Tools.updateWindowSize(this);
mControlLayout.refreshControlButtonPositions();
@@ -347,19 +396,65 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
}
private void runCraft(String versionId, JMinecraftVersionList.Version version) throws Throwable {
- if(Tools.LOCAL_RENDERER == null) {
- Tools.LOCAL_RENDERER = LauncherPreferences.PREF_RENDERER;
+ String assetVersion;
+ try {
+ if (version.inheritsFrom != null) { // We are almost definitely modded if this runs
+ File vanillaJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + version.inheritsFrom + "/" + version.inheritsFrom + ".json");
+ JMinecraftVersionList.Version vanillaJson;
+ try { // Get the vanilla json from modded instance
+ vanillaJson = Tools.GLOBAL_GSON.fromJson(Tools.read(vanillaJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class);
+ } catch (IOException ignored) { // Should never happen, we check for this in MinecraftDownloader().start()
+ throw new RuntimeException(getString(R.string.error_vanilla_json_corrupt));
+ }
+ // Something went wrong if this is somehow not the case anymore
+ if (!Objects.equals(vanillaJson.assets, vanillaJson.assetIndex.id))
+ Tools.showErrorRemote(new RuntimeException(getString(R.string.error_vanilla_json_corrupt)));
+ assetVersion = vanillaJson.assets;
+ } else {
+ // Else assume we are vanilla
+ if (!Objects.equals(version.assets, version.assetIndex.id))
+ Tools.showErrorRemote(new RuntimeException(getString(R.string.error_vanilla_json_corrupt)));
+ assetVersion = version.assets;
+ }
+ } catch (RuntimeException ignored){
+ assetVersion = "legacy";
+ } // If this fails.. oh well.
+
+ // FIXME: Automatic detection should be based on provided hint GLFW_CONTEXT_VERSION_MAJOR and GLFW_CONTEXT_VERSION_MINOR
+ // Autoselect renderer
+ if (Tools.LOCAL_RENDERER == null) {
+ // Preferably we could detect when it is modded and swap to zink however that would also
+ // cover optifine and vanilla+ configurations which are relatively common, degrading their
+ // experience for no reason. We will compromise with just having users do it themselves.
+ Tools.LOCAL_RENDERER = "opengles2";
+ // MobileGlues becomes available post 1.17. It has superior compatibility with mods
+ // while having fairly similar performance compared to GL4ES-based forks.
+ if(assetVersion.matches("\\d+") || // Should match all digits, which is the modern assetVersioning
+ "1.17".equals(assetVersion) ||
+ "1.18".equals(assetVersion) ||
+ "1.19".equals(assetVersion) ||
+ // Angelica gives us GL3.3core on 1.7.10, it's a unique case.
+ hasMods("angelica")) Tools.LOCAL_RENDERER = "opengles_mobileglues";
}
if(!Tools.checkRendererCompatible(this, Tools.LOCAL_RENDERER)) {
Tools.RenderersList renderersList = Tools.getCompatibleRenderers(this);
String firstCompatibleRenderer = renderersList.rendererIds.get(0);
Log.w("runCraft","Incompatible renderer "+Tools.LOCAL_RENDERER+ " will be replaced with "+firstCompatibleRenderer);
Tools.LOCAL_RENDERER = firstCompatibleRenderer;
+ runOnUiThread(() -> Toast.makeText(this, R.string.autorendererselectfailed, Toast.LENGTH_LONG).show());
Tools.releaseRenderersCache();
}
+
+ // MCL-3732 Mitigation
+ // I don't trust the bug tracker. 'server-resource-pack" was removed in 1.20.3-pre3
+ // so we use 12 to detect that. We still generate till 1.20.5 else we don't cover
+ // 1.20.3-pre2 and such. Better to over than to under.
+ File folder = new File(Tools.getGameDirPath(minecraftProfile), "server-resource-pack");
+ try {
+ if (Integer.parseInt(assetVersion) <= 12) folder.mkdir();
+ } catch (NumberFormatException e) { folder.mkdir(); }
+
MinecraftAccount minecraftAccount = PojavProfile.getCurrentProfileContent(this, null);
- Logger.appendToLog("--------- Starting game with Launcher Debug!");
- Tools.printLauncherInfo(versionId, Tools.isValidString(minecraftProfile.javaArgs) ? minecraftProfile.javaArgs : LauncherPreferences.PREF_CUSTOM_JAVA_ARGS);
JREUtils.redirectAndPrintJRELog();
LauncherProfiles.load();
int requiredJavaVersion = 8;
@@ -573,4 +668,23 @@ public boolean dispatchTrackballEvent(MotionEvent ev) {
return minecraftGLView.dispatchCapturedPointerEvent(ev);
else return super.dispatchTrackballEvent(ev);
}
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ if (hasFocus) {
+ Tools.setFullscreen(this, setFullscreen());
+ }
+ super.onWindowFocusChanged(hasFocus);
+ CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_FOCUSED, hasFocus ? 1 : 0);
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ super.onTrimMemory(level);
+ }
+
+ @Override
+ public void onBackPressed() {
+ super.onBackPressed();
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java
index 5e84141d93..022b32690a 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MinecraftGLSurface.java
@@ -1,6 +1,8 @@
package net.kdt.pojavlaunch;
import static net.kdt.pojavlaunch.MainActivity.touchCharInput;
+import static net.kdt.pojavlaunch.Tools.LOCAL_RENDERER;
+import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE;
import static net.kdt.pojavlaunch.utils.MCOptionUtils.getMcScale;
import static org.lwjgl.glfw.CallbackBridge.sendMouseButton;
import static org.lwjgl.glfw.CallbackBridge.windowHeight;
@@ -13,6 +15,7 @@
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
+import android.view.Display;
import android.view.InputDevice;
import android.view.KeyEvent;
import android.view.MotionEvent;
@@ -39,9 +42,13 @@
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import net.kdt.pojavlaunch.utils.JREUtils;
import net.kdt.pojavlaunch.utils.MCOptionUtils;
+import net.kdt.pojavlaunch.utils.TouchControllerUtils;
+import org.libsdl.app.SDLActivity;
+import org.libsdl.app.SDLControllerManager;
import org.lwjgl.glfw.CallbackBridge;
+
import fr.spse.gamepad_remapper.GamepadHandler;
import fr.spse.gamepad_remapper.RemapperManager;
import fr.spse.gamepad_remapper.RemapperView;
@@ -77,12 +84,15 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame
final Object mSurfaceReadyListenerLock = new Object();
/* View holding the surface, either a SurfaceView or a TextureView */
View mSurface;
+ String TAG = "MinecraftGLSurface";
private final InGameEventProcessor mIngameProcessor = new InGameEventProcessor(mSensitivityFactor);
private final InGUIEventProcessor mInGUIProcessor = new InGUIEventProcessor();
private TouchEventProcessor mCurrentTouchProcessor = mInGUIProcessor;
private AndroidPointerCapture mPointerCapture;
private boolean mLastGrabState = false;
+ public static boolean sdlEnabled = false;
+ boolean useSurfaceView = LauncherPreferences.PREF_USE_ALTERNATE_SURFACE;
public MinecraftGLSurface(Context context) {
this(context, null);
@@ -92,6 +102,7 @@ public MinecraftGLSurface(Context context, AttributeSet attributeSet) {
super(context, attributeSet);
setFocusable(true);
CallbackBridge.setDirectGamepadEnableHandler(this);
+ SDLControllerManager.setDirectGamepadEnableHandler(this);
}
@RequiresApi(api = Build.VERSION_CODES.O)
@@ -109,7 +120,11 @@ private void setUpPointerCapture(AbstractTouchpad touchpad) {
public void start(boolean isAlreadyRunning, AbstractTouchpad touchpad){
if(Tools.isAndroid8OrHigher()) setUpPointerCapture(touchpad);
mInGUIProcessor.setAbstractTouchpad(touchpad);
- if(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE){
+ // Kopper Zink has orientation issues on SurfaceView
+ try {
+ useSurfaceView = useSurfaceView && !LOCAL_RENDERER.equals("opengles3_desktopgl_zink_kopper");
+ } catch (NullPointerException ignored){}
+ if(useSurfaceView){
SurfaceView surfaceView = new SurfaceView(getContext());
mSurface = surfaceView;
@@ -132,7 +147,15 @@ public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width,
}
@Override
- public void surfaceDestroyed(@NonNull SurfaceHolder holder) {}
+ public void surfaceDestroyed(@NonNull SurfaceHolder holder) {
+ /*
+ Surface recreation in SurfaceView happens very often. When tabbing back in from
+ out, when minimizing floating window, when turning into floating window, etc.
+ Whenever the surface isn't in view, it is destroyed. When going into floating
+ window, it appears to automatically release the associated ANativeWindow. This
+ can cause a crash if not handled.
+ */
+ }
});
((ViewGroup)getParent()).addView(surfaceView);
@@ -163,6 +186,11 @@ public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surface, int wid
@Override
public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surface) {
+ /*
+ Surface recreation in TextureView can only really happen once, when turning
+ into a floating window. Subsequent turns to floating window no longer trigger
+ recreation. Tabbing out and in does not trigger recreation.
+ */
return true;
}
@@ -192,6 +220,13 @@ public boolean onTouchEvent(MotionEvent e) {
if(toolType == MotionEvent.TOOL_TYPE_MOUSE) {
if(Tools.isAndroid8OrHigher() &&
mPointerCapture != null) {
+ // Can't handleAutomaticCapture if mouse isn't captured
+ if (!CallbackBridge.isGrabbing() // Only capture if not in menu and user said so
+ && !PREF_MOUSE_GRAB_FORCE) {
+ // This returns true but we really can't consume this.
+ // Else we don't receive ACTION_MOVE
+ return !dispatchGenericMotionEvent(e);
+ }
mPointerCapture.handleAutomaticCapture();
return true;
}
@@ -202,16 +237,17 @@ public boolean onTouchEvent(MotionEvent e) {
CallbackBridge.sendCursorPos( e.getX(i) * LauncherPreferences.PREF_SCALE_FACTOR, e.getY(i) * LauncherPreferences.PREF_SCALE_FACTOR);
return true; //mouse event handled successfully
}
+ TouchControllerUtils.processTouchEvent(e, this);
if (mIngameProcessor == null || mInGUIProcessor == null) return true;
return mCurrentTouchProcessor.processTouchEvent(e);
}
private void createGamepad(View contextView, InputDevice inputDevice) {
- if(CallbackBridge.sGamepadDirectInput) {
+ if(CallbackBridge.sGamepadDirectInput && !sdlEnabled) {
mGamepadHandler = new DirectGamepad();
- }else {
+ }else if(!sdlEnabled) {
mGamepadHandler = new Gamepad(contextView, inputDevice, DefaultDataProvider.INSTANCE, true);
- }
+ }else mGamepadHandler = (code, value) -> {}; // Ensure it isn't null while also not processing the events.
}
/**
@@ -220,9 +256,22 @@ private void createGamepad(View contextView, InputDevice inputDevice) {
@SuppressLint("NewApi")
@Override
public boolean dispatchGenericMotionEvent(MotionEvent event) {
+ if(sdlEnabled && Gamepad.isGamepadEvent(event)) {
+ final MotionEvent copy = MotionEvent.obtain(event);
+ PojavApplication.sExecutorService.execute(()->{
+ try {
+ MainActivity.motionListener.onGenericMotion(this, copy);
+ copy.recycle();
+ } catch (Throwable ignored) {
+ Log.e(TAG, "SDL failed to send motionevent!");
+ }
+ });
+ return true;
+ }
+ super.dispatchGenericMotionEvent(event);
int mouseCursorIndex = -1;
- if(Gamepad.isGamepadEvent(event)){
+ if(!sdlEnabled && Gamepad.isGamepadEvent(event)){
if(mGamepadHandler == null) createGamepad(this, event.getDevice());
mInputManager.handleMotionEventInput(getContext(), event, mGamepadHandler);
@@ -239,9 +288,9 @@ public boolean dispatchGenericMotionEvent(MotionEvent event) {
// Make sure we grabbed the mouse if necessary
updateGrabState(CallbackBridge.isGrabbing());
-
switch(event.getActionMasked()) {
case MotionEvent.ACTION_HOVER_MOVE:
+ case MotionEvent.ACTION_MOVE:
CallbackBridge.mouseX = (event.getX(mouseCursorIndex) * LauncherPreferences.PREF_SCALE_FACTOR);
CallbackBridge.mouseY = (event.getY(mouseCursorIndex) * LauncherPreferences.PREF_SCALE_FACTOR);
CallbackBridge.sendCursorPos(CallbackBridge.mouseX, CallbackBridge.mouseY);
@@ -293,8 +342,21 @@ public boolean processKeyEvent(KeyEvent event) {
return true;
}
}
-
- if(Gamepad.isGamepadEvent(event)){
+ // Android bundles in garbage KeyEvents for compatibility with old apps
+ // that don't have controller code so we are, checking for em.
+ boolean isGamepadEvent = Gamepad.isGamepadEvent(event);
+ if (sdlEnabled && isGamepadEvent) {
+ final KeyEvent copy = new KeyEvent(event);
+ PojavApplication.sExecutorService.execute(() -> {
+ try {
+ SDLActivity.handleKeyEvent(this, eventKeycode, copy, null);
+ } catch (Throwable ignored) {
+ Log.e(TAG, "SDL failed to send keyevent!");
+ }
+ });
+ return true;
+ }
+ if(!sdlEnabled && isGamepadEvent){
if(mGamepadHandler == null) createGamepad(this, event.getDevice());
mInputManager.handleKeyEventInput(getContext(), event, mGamepadHandler);
@@ -358,7 +420,7 @@ public void refreshSize(boolean immediate) {
Log.w("MGLSurface", "Attempt to refresh size on null surface");
return;
}
- if(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE){
+ if(useSurfaceView){
SurfaceView view = (SurfaceView) mSurface;
if(view.getHolder() != null){
view.getHolder().setFixedSize(windowWidth, windowHeight);
@@ -378,6 +440,14 @@ private void realStart(Surface surface){
// Initial size set. Request immedate refresh, otherwise the initial width and height for the game
// may be broken/unknown.
refreshSize(true);
+ // Ensures we run at correct refresh rate (should also NOT change the resolution being used)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ float maxHz = 120f; // Set to 120 by default just to be safe
+ for (float altHz : getDisplay().getMode().getAlternativeRefreshRates()) {
+ maxHz = Math.max(maxHz, altHz);
+ }
+ surface.setFrameRate(maxHz, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT, Surface.CHANGE_FRAME_RATE_ONLY_IF_SEAMLESS);
+ }
//Load Minecraft options:
MCOptionUtils.set("fullscreen", "off");
@@ -412,9 +482,10 @@ private TouchEventProcessor pickEventProcessor(boolean isGrabbing) {
}
private void updateGrabState(boolean isGrabbing) {
- if(mLastGrabState != isGrabbing) {
+ TouchEventProcessor desiredProcessor = pickEventProcessor(isGrabbing);
+ if (mLastGrabState != isGrabbing || mCurrentTouchProcessor != desiredProcessor) {
mCurrentTouchProcessor.cancelPendingActions();
- mCurrentTouchProcessor = pickEventProcessor(isGrabbing);
+ mCurrentTouchProcessor = desiredProcessor;
mLastGrabState = isGrabbing;
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java
index 3b7f7c7580..76ca98ca16 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/NewJREUtil.java
@@ -1,17 +1,27 @@
package net.kdt.pojavlaunch;
import static net.kdt.pojavlaunch.Architecture.archAsString;
+import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture;
+import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR;
+import static net.kdt.pojavlaunch.Tools.isOnline;
import android.app.Activity;
+import android.content.Context;
import android.content.res.AssetManager;
import android.util.Log;
+import com.kdt.mcgui.ProgressLayout;
+
import net.kdt.pojavlaunch.multirt.MultiRTUtils;
import net.kdt.pojavlaunch.multirt.Runtime;
+import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
import net.kdt.pojavlaunch.utils.MathUtils;
import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+import java.io.File;
+import java.io.FileInputStream;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
@@ -56,7 +66,7 @@ private static InternalRuntime getInternalRuntime(Runtime runtime) {
}
private static MathUtils.RankedValue getNearestInstalledRuntime(int targetVersion) {
- List runtimes = MultiRTUtils.getRuntimes();
+ List runtimes = MultiRTUtils.getInstalledRuntimes();
return MathUtils.findNearestPositive(targetVersion, runtimes, (runtime)->runtime.javaVersion);
}
@@ -84,7 +94,7 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion
// Check whether the selection is an internal runtime
InternalRuntime internalRuntime = getInternalRuntime(runtime);
// If it is, check if updates are available from the APK file
- if(internalRuntime != null) {
+ if (internalRuntime != null) {
// Not calling showRuntimeFail on failure here because we did, technically, find the compatible runtime
return checkInternalRuntime(assetManager, internalRuntime);
}
@@ -100,6 +110,18 @@ public static boolean installNewJreIfNeeded(Activity activity, JMinecraftVersion
nearestInternalRuntime, nearestInstalledRuntime, (value)->value.rank
);
+ // Check if the selected runtime actually exists in the APK, else download it
+ // If it isn't InternalRuntime then it wasn't in the apk in the first place!
+ if (selectedRankedRuntime.value instanceof InternalRuntime)
+ if (!checkInternalRuntime(assetManager, (InternalRuntime) selectedRankedRuntime.value)) {
+ if (nearestInstalledRuntime == null) // If this was non-null then it would be a valid runtime and we can leave it be
+ tryDownloadRuntime(activity, gameRequiredVersion);
+ // This means the internal runtime didn't extract so let's use installed instead
+ // This also refreshes it so after the runtime download, it can find the new runtime
+ selectedRankedRuntime = getNearestInstalledRuntime(gameRequiredVersion);
+ }
+
+
// No possible selections
if(selectedRankedRuntime == null) {
showRuntimeFail(activity, versionInfo);
@@ -141,9 +163,51 @@ private static void showRuntimeFail(Activity activity, JMinecraftVersionList.Ver
activity.getString(R.string.multirt_nocompatiblert, verInfo.javaVersion.majorVersion));
}
+ public static boolean isJavaVersionAvailableForDownload(int version) {
+ for (ExternalRuntime javaVersion : ExternalRuntime.values()) {
+ if (javaVersion.majorVersion == version) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static String getJreSource(int javaVersion, String arch){
+ return String.format("https://github.com/AngelAuraMC/angelauramc-openjdk-build/releases/download/download_jre%1$s/jre%1$s-android-%2$s.tar.xz", javaVersion, arch);
+ }
+ /**
+ * @return whether installation was successful or not
+ */
+ private static void tryDownloadRuntime(Context activity, int javaVersion){
+ if (!isOnline(activity)) throw new RuntimeException(activity.getString(R.string.multirt_no_internet));
+ String arch = archAsString(getDeviceArchitecture());
+ // Checks for using this method
+ if (!isJavaVersionAvailableForDownload(javaVersion)) throw new RuntimeException("This is not an available JRE version");
+ if ((getDeviceArchitecture() == Architecture.ARCH_X86 && javaVersion >= 21)) throw new RuntimeException("x86 is not supported on Java"+javaVersion);
+ try {
+ File outputFile = new File(Tools.DIR_CACHE, String.format("jre%s-android-%s.tar.xz", javaVersion, arch));
+ DownloaderProgressWrapper monitor = new DownloaderProgressWrapper(R.string.newdl_downloading_jre_runtime,
+ ProgressLayout.UNPACK_RUNTIME);
+ monitor.extraString = Integer.toString(javaVersion);
+ DownloadUtils.downloadFileMonitored(
+ getJreSource(javaVersion, arch),
+ outputFile,
+ null,
+ monitor
+ );
+ String jreName = "External-" + javaVersion;
+ MultiRTUtils.installRuntimeNamed(NATIVE_LIB_DIR, new FileInputStream(outputFile), jreName);
+ MultiRTUtils.postPrepare(jreName);
+ outputFile.delete();
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to download Java "+javaVersion+" for "+arch, e);
+ }
+ }
+
private enum InternalRuntime {
JRE_17(17, "Internal-17", "components/jre-new"),
- JRE_21(21, "Internal-21", "components/jre-21");
+ JRE_21(21, "Internal-21", "components/jre-21"),
+ JRE_25(25, "Internal-25", "components/jre-25");
public final int majorVersion;
public final String name;
public final String path;
@@ -154,4 +218,24 @@ private enum InternalRuntime {
}
}
+ public enum ExternalRuntime {
+ JRE_8(8, "External-8"),
+ JRE_17(17, "External-17"),
+ JRE_21(21, "External-21"),
+ JRE_25(25, "External-25");
+ public final int majorVersion;
+ public final String name;
+ public final String downloadLink;
+ public boolean isDownloading = false;
+
+ ExternalRuntime(int majorVersion, String name) {
+ this.majorVersion = majorVersion;
+ this.name = name;
+ this.downloadLink = getJreSource(majorVersion, archAsString(getDeviceArchitecture()));
+ }
+ public void downloadRuntime(Context activity){
+ tryDownloadRuntime(activity, majorVersion);
+ }
+ }
+
}
\ No newline at end of file
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 c65aa58add..dd7e770ff8 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavApplication.java
@@ -39,7 +39,7 @@ public void onCreate() {
// Write to file, since some devices may not able to show error
FileUtils.ensureParentDirectory(crashFile);
PrintStream crashStream = new PrintStream(crashFile);
- crashStream.append("PojavLauncher crash report\n");
+ crashStream.append("Copper crash report\n");
crashStream.append(" - Time: ").append(DateFormat.getDateTimeInstance().format(new Date())).append("\n");
crashStream.append(" - Device: ").append(Build.PRODUCT).append(" ").append(Build.MODEL).append("\n");
crashStream.append(" - Android version: ").append(Build.VERSION.RELEASE).append("\n");
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java
index ae9d5421e7..e8a95e88ea 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/PojavProfile.java
@@ -8,6 +8,13 @@
import net.kdt.pojavlaunch.value.MinecraftAccount;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
public class PojavProfile {
private static final String PROFILE_PREF = "pojav_profile";
private static final String PROFILE_PREF_FILE = "file";
@@ -29,6 +36,27 @@ public static String getCurrentProfileName(Context ctx) {
}
return name;
}
+
+ public static List getAllProfiles(){
+ List mcAccountList = new ArrayList<>();;
+ for (String accountName : getAllProfilesList()){
+ if (MinecraftAccount.load(accountName) != null) {
+ mcAccountList.add(MinecraftAccount.load(accountName));
+ }
+ }
+ return mcAccountList;
+ }
+
+ public static List getAllProfilesList(){
+ List accountList = new ArrayList<>();
+ File accountFolder = new File(Tools.DIR_ACCOUNT_NEW);
+ if(accountFolder.exists() && accountFolder.list() != null){
+ for (String fileName : Objects.requireNonNull(accountFolder.list())) {
+ accountList.add(fileName.substring(0, fileName.length() - 5));
+ }
+ }
+ return accountList;
+ }
public static void setCurrentProfile(@NonNull Context ctx, @Nullable Object obj) {
SharedPreferences.Editor pref = getPrefs(ctx).edit();
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 ed09c85320..d79718ccd3 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java
@@ -2,10 +2,14 @@
import static android.os.Build.VERSION.SDK_INT;
import static android.os.Build.VERSION_CODES.P;
+import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid;
+import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture;
import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
+import static net.kdt.pojavlaunch.PojavProfile.getAllProfiles;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_IGNORE_NOTCH;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_NOTCH_SIZE;
+import android.annotation.SuppressLint;
import android.app.Activity;
import android.app.ActivityManager;
import android.app.NotificationChannel;
@@ -21,6 +25,8 @@
import android.database.Cursor;
import android.hardware.Sensor;
import android.hardware.SensorManager;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
@@ -32,6 +38,7 @@
import android.util.ArrayMap;
import android.util.DisplayMetrics;
import android.util.Log;
+import android.view.InputDevice;
import android.view.View;
import android.view.WindowManager;
import android.widget.EditText;
@@ -58,6 +65,7 @@
import net.kdt.pojavlaunch.multirt.Runtime;
import net.kdt.pojavlaunch.plugins.FFmpegPlugin;
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.tasks.AsyncAssetManager;
import net.kdt.pojavlaunch.utils.DateUtils;
import net.kdt.pojavlaunch.utils.DownloadUtils;
import net.kdt.pojavlaunch.utils.FileUtils;
@@ -74,12 +82,15 @@
import org.apache.commons.codec.binary.Hex;
import org.apache.commons.io.IOUtils;
+import org.libsdl.app.SDLControllerManager;
import org.lwjgl.glfw.CallbackBridge;
import java.io.BufferedInputStream;
+import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
+import java.io.FileReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -87,6 +98,8 @@
import java.io.StringWriter;
import java.lang.ref.WeakReference;
import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
import java.net.URLConnection;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
@@ -101,22 +114,22 @@
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 = "PojavLauncher";
+ public static String APP_NAME = "Copper";
public static final Gson GLOBAL_GSON = new GsonBuilder().setPrettyPrinting().create();
- public static final String URL_HOME = "https://pojavlauncherteam.github.io";
+ public static final String URL_HOME = "https://angelauramc.dev/wiki";
public static String NATIVE_LIB_DIR;
public static String DIR_DATA; //Initialized later to get context
public static File DIR_CACHE;
public static String MULTIRT_HOME;
public static String LOCAL_RENDERER = null;
public static int DEVICE_ARCHITECTURE;
- public static final String LAUNCHERPROFILES_RTPREFIX = "pojav://";
+ public static final String LAUNCHERPROFILES_RTPREFIX = "amethyst://";
// New since 3.3.1
public static String DIR_ACCOUNT_NEW;
- public static String DIR_GAME_HOME = Environment.getExternalStorageDirectory().getAbsolutePath() + "/games/PojavLauncher";
+ public static String DIR_GAME_HOME = Environment.getExternalStorageDirectory().getAbsolutePath() + "/games/Amethyst";
public static String DIR_GAME_NEW;
// New since 3.0.0
@@ -133,13 +146,16 @@ public final class Tools {
public static String CTRLMAP_PATH;
public static String CTRLDEF_FILE;
private static RenderersList sCompatibleRenderers;
+ public static int iLwjglVersion = 0;
+ public static String sLwjglVersion = null;
+ public static String lwjglNativesDir = null;
private static File getPojavStorageRoot(Context ctx) {
if(SDK_INT >= 29) {
return ctx.getExternalFilesDir(null);
}else{
- return new File(Environment.getExternalStorageDirectory(),"games/PojavLauncher");
+ return new File(Environment.getExternalStorageDirectory(),"games/Amethyst");
}
}
@@ -202,21 +218,93 @@ public static void initStorageConstants(Context ctx){
CTRLDEF_FILE = DIR_GAME_HOME + "/controlmap/default.json";
}
+ @SuppressLint("PrivateApi")
+ private static String systemPropertiesGet(String systemProperty) throws NoSuchMethodException, ClassNotFoundException, InvocationTargetException, IllegalAccessException {
+ Class> cSystemProperties = Class.forName("android.os.SystemProperties");
+ Method get = cSystemProperties.getMethod("get", String.class);
+ return (String) get.invoke(null, systemProperty);
+ }
+
+ private static boolean isAdreno740(){
+ try {
+ BufferedReader br = new BufferedReader(
+ new FileReader("/sys/class/kgsl/kgsl-3d0/gpu_model")
+ );
+ String gpuRenderer = br.readLine();
+ return gpuRenderer != null &&
+ gpuRenderer.toLowerCase().contains("adreno") &&
+ gpuRenderer.contains("740");
+ } catch (IOException e) {
+ // If it doesn't exist, we definitely aren't on 740
+ return false;
+ }
+ }
+
+ /**
+ * Detects whether or not you are on OneUI and using Adreno 740
+ *
+ * Mesa sets it to 0 by default due to vendor quirks
+ *
+ * It is possible that OneUI simply deviates from this commonality, hence why
+ *
+ * this is a common fix
+ *
+ * @return Whether or not to export FD_DEV_FEATURES=enable_ubwc_flag_hint=1
+ */
+ public static boolean shouldUseUBWC() {
+ try {
+ boolean isSamsung = Build.MANUFACTURER.equalsIgnoreCase("samsung");
+ boolean isOneUI = !systemPropertiesGet("ro.build.version.oneui").isBlank();
+ return isOneUI && isSamsung && isAdreno740();
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+
+ /**
+ * @return The selected "Custom path" of the current profile
+ */
+ @NonNull
+ private static File getGameDir() {
+ return getGameDirPath(LauncherProfiles.getCurrentProfile());
+ }
+
/**
- * Optimization mods based on Sodium can mitigate the render distance issue. Check if Sodium
- * or its derivative is currently installed to skip the render distance check.
+ * Searches for mod in mods directory of current selected profile
+ * Not case-sensitive
+ * @param filenames Filename(s) of the .jar mod(s)
+ * @return Whether or not the .jar is found
+ */
+ public static boolean hasMods(String... filenames) {
+ File gameDir = getGameDir();
+ File modsDir = new File(gameDir, "mods");
+ File[] modFiles = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
+ if (modFiles == null) return false;
+ for (File file : modFiles) {
+ for (String filename : filenames)
+ if (file.getName().toLowerCase().contains(filename.toLowerCase())) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Search for TouchController mod to automatically enable TouchController mod support.
+ *
* @param gameDir current game directory
- * @return whether sodium or a sodium-based mod is installed
+ * @return whether TouchController is found
*/
- private static boolean hasSodium(File gameDir) {
+ public static boolean hasTouchController(File gameDir) {
File modsDir = new File(gameDir, "mods");
File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
- if(mods == null) return false;
- for(File file : mods) {
- String name = file.getName();
- if(name.contains("sodium") ||
- name.contains("embeddium") ||
- name.contains("rubidium")) return true;
+ if (mods == null) {
+ return false;
+ }
+ for (File file : mods) {
+ String name = file.getName().toLowerCase(Locale.ROOT);
+ if (name.contains("touchcontroller")) {
+ return true;
+ }
}
return false;
}
@@ -237,10 +325,12 @@ private static boolean affectedByRenderDistanceIssue() {
return info.isAdreno() && info.glesMajorVersion >= 3;
}
- private static boolean checkRenderDistance(File gamedir) {
+ private static String[] sodiumMods = {"sodium", "embeddium", "rubidium", "xenon"};
+
+ private static boolean affectedByLTWRenderDistanceIssue() {
if(!"opengles3_ltw".equals(Tools.LOCAL_RENDERER)) return false;
if(!affectedByRenderDistanceIssue()) return false;
- if(hasSodium(gamedir)) return false;
+ if(hasMods(sodiumMods)) return false;
int renderDistance;
try {
@@ -283,7 +373,9 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc
}
LauncherProfiles.load();
File gamedir = Tools.getGameDirPath(minecraftProfile);
- if(checkRenderDistance(gamedir)) {
+ startControllableMitigation(activity, gamedir);
+ startOldLegacy4JMitigation(activity, gamedir);
+ if(affectedByLTWRenderDistanceIssue()) {
LifecycleAwareAlertDialog.DialogCreator dialogCreator = ((alertDialog, dialogBuilder) ->
dialogBuilder.setMessage(activity.getString(R.string.ltw_render_distance_warning_msg))
.setPositiveButton(android.R.string.ok, (d, w)->{}));
@@ -312,11 +404,11 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc
OldVersionsUtils.selectOpenGlVersion(versionInfo);
- String launchClassPath = generateLaunchClassPath(versionInfo, versionId);
+ String launchClasspath = generateLaunchClasspath(versionInfo, versionId);
List javaArgList = new ArrayList<>();
- getCacioJavaArgs(javaArgList, runtime.javaVersion == 8);
+ getCacioJavaArgs(javaArgList, runtime.javaVersion == 8, activity);
if (versionInfo.logging != null) {
String configFile = Tools.DIR_DATA + "/security/" + versionInfo.logging.client.file.id.replace("client", "log4j-rce-patch");
@@ -327,16 +419,69 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc
}
File versionSpecificNativesDir = new File(Tools.DIR_CACHE, "natives/"+versionId);
+ StringBuilder javaLibraryPath = new StringBuilder();
+
+ // Add which lwjgl natives to use into classpath
+ javaLibraryPath.append(lwjglNativesDir).append(":");
+
+ // Add JNA native if needed
+ javaLibraryPath.append(Tools.NATIVE_LIB_DIR).append(":");
if(versionSpecificNativesDir.exists()) {
String dirPath = versionSpecificNativesDir.getAbsolutePath();
- javaArgList.add("-Djava.library.path="+dirPath+":"+Tools.NATIVE_LIB_DIR);
+ javaLibraryPath.append(dirPath).append(":");
javaArgList.add("-Djna.boot.library.path="+dirPath);
}
+ javaArgList.add("-Djava.library.path="+javaLibraryPath);
javaArgList.addAll(Arrays.asList(getMinecraftJVMArgs(versionId, gamedir)));
- javaArgList.add("-cp");
- javaArgList.add(launchClassPath + ":" + getLWJGL3ClassPath());
+ javaArgList.add("-cp"); javaArgList.add(launchClasspath);
+
+ // Some modloaders (babric) don't fully respect java.libary.path and only use the native lib dir
+ // This arg makes them use it. LWJGL prioritizes this path during native loading as well.
+ javaArgList.add("-Dorg.lwjgl.librarypath="+lwjglNativesDir);
+
+ // Forge 1.6.4 crash mitigation
+ // https://github.com/MinecraftForge/FML/blob/f1b3381e61fac1a0ae90f521223c6bc613eb4888/common/cpw/mods/fml/common/asm/FMLSanityChecker.java#L192-L208
+ // It for some reason fails certification and crashes because it thinks Minecraft is corrupted.
+ // This also has no loading screen as a result.
+ javaArgList.add("-Dfml.ignoreInvalidMinecraftCertificates=true");
+
+ // imgui-java set library name to use. This because Axiom uses a fork with different library naming
+ // logic that doesn't seem to appear in the main repository. I'm not gonna work with that.
+ javaArgList.add("-Dimgui.library.name=imgui-java");
+ // We use an abomination to support all DH versions with a single library.
+ javaArgList.add("-DZstdNativePath="+Tools.NATIVE_LIB_DIR+"/libzstd-jni-1.5.7-6-dhcompat.so");
+ // We only ever reach this point when user has already used the force run switch
+ boolean hasSodiumMod = false;
+ for (String modName : sodiumMods) {
+ if (hasMods(sodiumMods)) {
+ hasSodiumMod = true;
+ File mixinPropertiesConfigFile = new File(getGameDir(), "config/" + modName + "-mixins.properties");
+ // Write mixin configs to somewhat help stability. We don't want more people complaining.
+ String[] propertiesToAdd = {
+ "mixin.features.buffer_builder.intrinsics=false",
+ "mixin.features.chunk_rendering=false"
+ };
+ List mixinPropertiesConfigStrings = null;
+ try {
+ mixinPropertiesConfigStrings = org.apache.commons.io.FileUtils.readLines(mixinPropertiesConfigFile, "UTF-8");
+ } catch (IOException ignored) {}
+ if (mixinPropertiesConfigStrings == null) {
+ mixinPropertiesConfigStrings = new ArrayList<>();
+ }
+ for (String newLine : propertiesToAdd) {
+ if (!mixinPropertiesConfigStrings.contains(newLine)) {
+ mixinPropertiesConfigStrings.add(newLine);
+ }
+ }
+ try {
+ org.apache.commons.io.FileUtils.writeLines(mixinPropertiesConfigFile, mixinPropertiesConfigStrings);
+ } catch (IOException ignored) {} // If we can't write it, we tried our best.
+ }
+ }
+ // We use a janky lwjgl setup. We don't want more people complaining it crashes.
+ if (hasSodiumMod) javaArgList.add("-Dsodium.checks.issue2561=false");
javaArgList.add(versionInfo.mainClass);
javaArgList.addAll(Arrays.asList(launchArgs));
// ctx.appendlnToLog("full args: "+javaArgList.toString());
@@ -347,6 +492,109 @@ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAc
// If we returned, this means that the JVM exit dialog has been shown and we don't need to be active anymore.
// We never return otherwise. The process will be killed anyway, and thus we will become inactive
}
+ private static Logger.eventLogListener controllableMitigationLogListener;
+ /*
+ * This is does not work when debugging. This is not reliable.
+ * This is a monstrosity that races the mod, trying to ensure that when the folder is checked
+ * after extraction but before dlopen, it is empty, so it loads the bundled SDL2 we have instead
+ */
+ private static void startControllableMitigation(Activity activity ,File gamedir) {
+ String TAG = "ControllableMitigation";
+ File deleted = new File(gamedir + "/controllable_natives/SDL");
+ boolean hasControllable = false;
+ File modsDir = new File(gamedir, "mods");
+ File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
+ if (mods != null) {
+ for (File file : mods) {
+ String name = file.getName();
+ if (name.contains("controllable")) {
+ hasControllable = true;
+ break;
+ }
+ }
+ }
+ if (hasControllable) {
+ Tools.runOnUiThread(() -> {
+ Tools.dialog(activity, activity.getString(R.string.global_warning), activity.getString(R.string.controllableFound));
+ });
+ Thread mitigationThread = new Thread(() -> {
+ // This is total garbage but it seems to be the best jank for the job
+ Log.i(TAG, "Controllable detected! Starting mitigation thread");
+ try {org.apache.commons.io.FileUtils.deleteDirectory(deleted);} catch (IOException ignored) {}
+ while (!Thread.currentThread().isInterrupted()) {
+ // Looks for controllable_natives/SDL//libSDL2.so and
+ // deletes it. We can assume array index 0 because this dir gets fully deleted
+ // before the loop is started.
+ if (deleted.isDirectory()) {
+ if (deleted.listFiles().length > 0) {
+ if (deleted.listFiles()[0].listFiles().length > 0) {
+ if (deleted.listFiles()[0].listFiles()[0].exists()) {
+ deleted.listFiles()[0].listFiles()[0].delete();
+ break;
+ }
+ }
+ }
+ }
+ }
+ // We can end here because SdlNativeLibraryLoader only extracts libSDL2.so once
+ // If NativeLibrary can't find it in the folder to load() it uses java.library.path
+ Log.i(TAG, "Success! Ending Controllable crash mitigation..");
+ });
+ mitigationThread.start();
+ controllableMitigationLogListener = loggedLine -> {
+ // Hard off switch if it somehow didn't delete anything, just in case.
+ if (loggedLine.contains("Sound engine started") && mitigationThread.isAlive()) {
+ Log.i(TAG, "Nothing happened. Ending Controllable crash mitigation..");
+ Logger.removeLogListener(controllableMitigationLogListener);
+ mitigationThread.interrupt();
+ }
+ };
+ Logger.addLogListener(controllableMitigationLogListener);
+ }
+ }
+
+ private static Logger.eventLogListener oldL4JMitigationLogListener;
+ /// TODO: Remove when the time is right
+ /**
+ * Legacy4J for a long time had broken SDL detection for android, we need to check and
+ * accommodate this for now. At least until the broken logic are on versions considered
+ * obsolete.
+ *
+ * This is of course, very jank, it does not work for anything below 1.7.5 but why is anyone
+ * on that version anyway? Legacy4J has LTS for like all the versions.
+ */
+ private static void startOldLegacy4JMitigation(Activity activity, File gamedir) {
+ boolean hasLegacy4J = false;
+ File modsDir = new File(gamedir, "mods");
+ File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
+ if(mods != null) {
+ for (File file : mods) {
+ String name = file.getName();
+ if (name.contains("Legacy4J")) {
+ hasLegacy4J = true;
+ break;
+ }
+ }
+ }
+ if (hasLegacy4J) {
+ String TAG = "OldLegacy4JMitigation";
+ Log.i(TAG, "Legacy4J detected!");
+ oldL4JMitigationLogListener = loggedLine -> {
+ if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU && loggedLine.contains("literal{SDL3 (isXander's libsdl4j)} isn't supported in this system. GLFW will be used instead.")) {
+ Log.i(TAG, "Old version of Legacy4J detected! Force enabling SDL");
+ Tools.SDL.initializeControllerSubsystems();
+ Tools.runOnUiThread(() -> {
+ Tools.dialog(activity, activity.getString(R.string.global_warning), activity.getString(R.string.oldL4JFound));
+ });
+ Logger.removeLogListener(oldL4JMitigationLogListener);
+ } else if (LauncherPreferences.PREF_GAMEPAD_SDL_PASSTHRU && loggedLine.contains("Added SDL Controller Mappings")) {
+ Log.i(TAG, "Fixed version of Legacy4J detected! Have fun!");
+ Logger.removeLogListener(oldL4JMitigationLogListener);
+ }
+ };
+ Logger.addLogListener(oldL4JMitigationLogListener);
+ }
+ }
public static File getGameDirPath(@NonNull MinecraftProfile minecraftProfile){
if(minecraftProfile.gameDir != null){
@@ -387,7 +635,7 @@ public static void disableSplash(File dir) {
}
}
- public static void getCacioJavaArgs(List javaArgList, boolean isJava8) {
+ public static void getCacioJavaArgs(List javaArgList, boolean isJava8, Activity activity) {
// Caciocavallo config AWT-enabled version
javaArgList.add("-Djava.awt.headless=false");
javaArgList.add("-Dcacio.managed.screensize=" + AWTCanvasView.AWT_CANVAS_WIDTH + "x" + AWTCanvasView.AWT_CANVAS_HEIGHT);
@@ -398,10 +646,20 @@ public static void getCacioJavaArgs(List javaArgList, boolean isJava8) {
javaArgList.add("-Dawt.toolkit=net.java.openjdk.cacio.ctc.CTCToolkit");
javaArgList.add("-Djava.awt.graphicsenv=net.java.openjdk.cacio.ctc.CTCGraphicsEnvironment");
} else {
+ File caciocavavallo17Dir = new File(Tools.DIR_GAME_HOME, "caciocavallo17");
+ File[] caciocavallo17Jars = caciocavavallo17Dir.listFiles((f, s) ->s.contains("cacio-tta"));
+ if(caciocavallo17Jars == null || caciocavallo17Jars.length < 1) {
+ // We wanna avoid the launch being interrupted so we extract again if it isn't found
+ AsyncAssetManager.unpackComponents(activity);
+ caciocavallo17Jars = caciocavavallo17Dir.listFiles((f, s) ->s.contains("cacio-tta"));
+ if(caciocavallo17Jars == null || caciocavallo17Jars.length < 1)
+ throw new RuntimeException("Failed to extract required assets!");
+ }
+ javaArgList.add("-javaagent:"+caciocavallo17Jars[0].getAbsolutePath());
javaArgList.add("-Dawt.toolkit=com.github.caciocavallosilano.cacio.ctc.CTCToolkit");
javaArgList.add("-Djava.awt.graphicsenv=com.github.caciocavallosilano.cacio.ctc.CTCGraphicsEnvironment");
- javaArgList.add("-Djava.system.class.loader=com.github.caciocavallosilano.cacio.ctc.CTCPreloadClassLoader");
-
+ // This approach breaks kilt so we use an agent instead
+// javaArgList.add("-Djava.system.class.loader=com.github.caciocavallosilano.cacio.ctc.CTCPreloadClassLoader");
javaArgList.add("--add-exports=java.desktop/java.awt=ALL-UNNAMED");
javaArgList.add("--add-exports=java.desktop/java.awt.peer=ALL-UNNAMED");
javaArgList.add("--add-exports=java.desktop/sun.awt.image=ALL-UNNAMED");
@@ -438,24 +696,47 @@ public static void getCacioJavaArgs(List javaArgList, boolean isJava8) {
public static String[] getMinecraftJVMArgs(String versionName, File gameDir) {
JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionName, true);
- // Parse Forge 1.17+ additional JVM Arguments
- if (versionInfo.inheritsFrom == null || versionInfo.arguments == null || versionInfo.arguments.jvm == null) {
+ if (versionInfo.arguments == null || versionInfo.arguments.jvm == null)
return new String[0];
- }
Map varArgMap = new ArrayMap<>();
varArgMap.put("classpath_separator", ":");
varArgMap.put("library_directory", DIR_HOME_LIBRARY);
varArgMap.put("version_name", versionInfo.id);
- varArgMap.put("natives_directory", Tools.NATIVE_LIB_DIR);
+ varArgMap.put("natives_directory", Tools.DIR_CACHE.getAbsolutePath());
List minecraftArgs = new ArrayList<>();
- if (versionInfo.arguments != null) {
- for (Object arg : versionInfo.arguments.jvm) {
- if (arg instanceof String) {
- minecraftArgs.add((String) arg);
- } //TODO: implement (?maybe?)
- }
+ for (Object arg : versionInfo.arguments.jvm) {
+ if (arg instanceof String) {
+ // These are defined later on
+ if (((String) arg).contains("java.library.path")) {
+ continue;
+ }
+ if (arg.equals("-cp")) {
+ continue;
+ }
+ if (arg.equals("${classpath}")){
+ continue;
+ }
+ // Should fix Forge 1.17.1-37.0.12 and older from crashing
+ // Fixed in forge on https://github.com/MinecraftForge/MinecraftForge/pull/7919
+ // Released as Forge 1.17.1-37.0.13 in https://maven.minecraftforge.net/net/minecraftforge/forge/1.17.1-37.0.13/forge-1.17.1-37.0.13-changelog.txt
+ // yes this duplicates it, it's fine.
+ // FIXME: Workaround old bootstraplauncher <0.1.17 buggy behaviour. See FCL workaround
+ // https://github.com/FCL-Team/FoldCraftLauncher/blob/00e96bcf8ddc8a550e9aba6091a73d5bee973b54/FCLCore/src/main/java/com/tungsten/fclcore/download/MaintainTask.java#L198-L200
+ if (((String) arg).startsWith("-DignoreList=")){
+ minecraftArgs.add(arg+",${version_name}.jar");
+ continue;
+ }
+
+ // TODO: Implement adding launcher brand and version
+ if (((String) arg).contains("minecraft.launcher.brand") ||
+ ((String) arg).contains("minecraft.launcher.version")) {
+ continue;
+ }
+
+ minecraftArgs.add((String) arg);
+ } //TODO: implement (?maybe?)
}
return JSONUtils.insertJSONValueList(minecraftArgs.toArray(new String[0]), varArgMap);
}
@@ -542,55 +823,54 @@ public static String artifactToPath(DependentLibrary library) {
library.downloads.artifact.path != null)
return library.downloads.artifact.path;
String[] libInfos = library.name.split(":");
- return libInfos[0].replaceAll("\\.", "/") + "/" + libInfos[1] + "/" + libInfos[2] + "/" + libInfos[1] + "-" + libInfos[2] + ".jar";
- }
-
- public static String getClientClasspath(String version) {
- return DIR_HOME_VERSION + "/" + version + "/" + version + ".jar";
+ return libInfos[0].replaceAll("\\.", "/") + "/" + libInfos[1] + "/" + libInfos[2] + "/" + libInfos[1] + "-" + libInfos[2] + (libInfos.length == 4 ? "-" + libInfos[3] : "") + ".jar";
}
- private static String getLWJGL3ClassPath() {
- StringBuilder libStr = new StringBuilder();
- File lwjgl3Folder = new File(Tools.DIR_GAME_HOME, "lwjgl3");
- File[] lwjgl3Files = lwjgl3Folder.listFiles();
- if (lwjgl3Files != null) {
- for (File file: lwjgl3Files) {
- if (file.getName().endsWith(".jar")) {
- libStr.append(file.getAbsolutePath()).append(":");
- }
- }
- }
- // Remove the ':' at the end
- libStr.setLength(libStr.length() - 1);
- return libStr.toString();
- }
-
- private final static boolean isClientFirst = false;
- public static String generateLaunchClassPath(JMinecraftVersionList.Version info, String actualname) {
- StringBuilder finalClasspath = new StringBuilder(); //versnDir + "/" + version + "/" + version + ".jar:";
-
+ private static String getLibClasspath(JMinecraftVersionList.Version info){
+ StringBuilder libClasspath = new StringBuilder();
String[] classpath = generateLibClasspath(info);
-
- if (isClientFirst) {
- finalClasspath.append(getClientClasspath(actualname));
- }
for (String jarFile : classpath) {
- if (!FileUtils.exists(jarFile)) {
- Log.d(APP_NAME, "Ignored non-exists file: " + jarFile);
- continue;
- }
- finalClasspath.append((isClientFirst ? ":" : "")).append(jarFile).append(!isClientFirst ? ":" : "");
- }
- if (!isClientFirst) {
- finalClasspath.append(getClientClasspath(actualname));
+ libClasspath.append(jarFile).append(":");
}
+ // Remove the ':' at the end
+ libClasspath.setLength(libClasspath.length() - 1);
+ return libClasspath.toString();
+ }
- return finalClasspath.toString();
+ public static String getClientClasspath(String version) {
+ return DIR_HOME_VERSION + "/" + version + "/" + version + ".jar";
}
+ public static String generateLaunchClasspath(JMinecraftVersionList.Version info, String actualname) {
+ StringBuilder launchClasspath = new StringBuilder(); //versnDir + "/" + version + "/" + version + ".jar:";
+ String libClasspath = getLibClasspath(info); // Sets lwjglVersion, janky, but we can't get it any simpler
+ String internalLwjglVersion = iLwjglVersion >= 341 ? "3.4.1" : "3.3.3";
+ File lwjgl3Folder = new File(Tools.DIR_GAME_HOME, "lwjgl3/"+internalLwjglVersion);
+ String lwjglCore = lwjgl3Folder.getAbsolutePath() + "/lwjgl.jar";
+ String lwjglMerged = lwjgl3Folder.getAbsolutePath() + "/lwjgl-"+internalLwjglVersion+"-merged-modules";
+ String lwjglxFile = lwjgl3Folder + "/lwjgl-lwjglx.jar";
+ launchClasspath.append(lwjglCore).append(":");
+ // 2nd in priority in case we need to merge lwjgl.jar again for testing
+ launchClasspath.append(lwjglMerged).append(":");
+ File[] lwjglModules = lwjgl3Folder.listFiles(pathname ->
+ pathname.getName().endsWith(".jar") &&
+ // Exclude our two special jars which goes first and last
+ !pathname.getName().equals("lwjgl.jar") &&
+ !pathname.getName().endsWith("lwjglx.jar"));
+ if (lwjglModules != null) {
+ for (File lwjglModule : lwjglModules)
+ launchClasspath.append(lwjglModule.getAbsolutePath()).append(":");
+ } else Log.e("generateLaunchClasspath", "lwjgl modules are missing from components!");
+
+ launchClasspath.append(libClasspath).append(":");
+ launchClasspath.append(getClientClasspath(actualname));
+ // Anything LWJGL2 gets LWJGLX
+ if (iLwjglVersion <= 299) launchClasspath.append(":").append(lwjglxFile);
+ return launchClasspath.toString();
+ }
public static DisplayMetrics getDisplayMetrics(Activity activity) {
DisplayMetrics displayMetrics = new DisplayMetrics();
@@ -795,6 +1075,9 @@ public static void showErrorRemote(String rolledMessage, Throwable e) {
public static void dialogOnUiThread(final Activity activity, final CharSequence title, final CharSequence message) {
activity.runOnUiThread(()->dialog(activity, title, message));
}
+ public static void dialogOnUiThread(final Activity activity, final int title, final int message) {
+ dialogOnUiThread(activity, activity.getString(title), activity.getString(message));
+ }
public static void dialog(final Context context, final CharSequence title, final CharSequence message) {
new AlertDialog.Builder(context)
@@ -868,9 +1151,46 @@ private static void createLibraryInfo(DependentLibrary library) {
public static String[] generateLibClasspath(JMinecraftVersionList.Version info) {
List libDir = new ArrayList<>();
for (DependentLibrary libItem: info.libraries) {
+ // Look for LWJGL version
+ int libItemVersionStringOffset = 0;
+ if(libItem.name.startsWith("org.lwjgl.lwjgl:lwjgl:")) {
+ libItemVersionStringOffset = "org.lwjgl.lwjgl:lwjgl:".length();
+ } else if (libItem.name.startsWith("org.lwjgl:lwjgl:")) {
+ libItemVersionStringOffset = "org.lwjgl:lwjgl:".length();
+ }
+ // If we already have a valid LWJGL version, skip this block
+ if (libItemVersionStringOffset != 0 && (iLwjglVersion < 200 || iLwjglVersion > 999)) {
+ while (libItemVersionStringOffset < libItem.name.length()) {
+ char c = libItem.name.charAt(libItemVersionStringOffset);
+ if (c >= '0' && c <= '9') {
+ iLwjglVersion = iLwjglVersion * 10 + (c - '0');
+ } else if (c == '.') {
+ // skip dots
+ } else {
+ break; // If the dots and numbers stop then its time to finish
+ }
+ libItemVersionStringOffset++;
+ }
+ }
+
+ String libPath = Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(libItem);
+ if (!FileUtils.exists(libPath)) {
+ Log.d(APP_NAME, "Ignored non-exists file: " + libPath);
+ continue;
+ }
if(!checkRules(libItem.rules)) continue;
- libDir.add(Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(libItem));
+ libDir.add(libPath);
+ // Mitigation: Babric doesn't use asm-all for some reason so it does a classpath conflict
+ if (libItem.name.startsWith("org.ow2.asm:asm") && !libItem.name.startsWith("org.ow2.asm:asm-all:")){
+ libDir.remove(Tools.DIR_HOME_LIBRARY + "/" + artifactToPath(new DependentLibrary(){{
+ name = "org.ow2.asm:asm-all:5.0.4";
+ }} ));
+ }
}
+ // Scary message, but we aren't getting LWJGL 1.9.9 or 3.10.100 any time soon
+ if (iLwjglVersion < 200 || iLwjglVersion > 999) throw new RuntimeException("Unable to determine LWJGL version, JSON may be corrupt.");
+ sLwjglVersion = iLwjglVersion >= 341 ? "3.4.1" : "3.3.3";
+ lwjglNativesDir = String.format("%s/lwjgl-%s-natives/%s", Tools.DIR_DATA, sLwjglVersion, archAsStringAndroid(getDeviceArchitecture()));
return libDir.toArray(new String[0]);
}
@@ -896,7 +1216,7 @@ public static JMinecraftVersionList.Version getVersionInfo(String versionName, b
insertSafety(inheritsVer, customVer,
"assetIndex", "assets", "id",
"mainClass", "minecraftArguments",
- "releaseTime", "time", "type"
+ "releaseTime", "time", "type", "inheritsFrom"
);
// Go through the libraries, remove the ones overridden by the custom version
@@ -1364,8 +1684,8 @@ public static RenderersList getCompatibleRenderers(Context context) {
String[] defaultRenderers = resources.getStringArray(R.array.renderer_values);
String[] defaultRendererNames = resources.getStringArray(R.array.renderer);
boolean deviceHasVulkan = checkVulkanSupport(context.getPackageManager());
- // Currently, only 32-bit x86 does not have the Zink binary
- boolean deviceHasZinkBinary = !(Architecture.is32BitsDevice() && Architecture.isx86Device());
+ // Zink is now also optional because it sucks
+ boolean deviceHasOSMesaZinkBinary = new File(Tools.NATIVE_LIB_DIR, "libOSMesa.so").exists();
boolean deviceHasOpenGLES3 = JREUtils.getDetectedVersion() >= 3;
// LTW is an optional proprietary dependency
boolean appHasLtw = new File(Tools.NATIVE_LIB_DIR, "libltw.so").exists();
@@ -1374,7 +1694,7 @@ public static RenderersList getCompatibleRenderers(Context context) {
for(int i = 0; i < defaultRenderers.length; i++) {
String rendererId = defaultRenderers[i];
if(rendererId.contains("vulkan") && !deviceHasVulkan) continue;
- if(rendererId.contains("zink") && !deviceHasZinkBinary) continue;
+ if(rendererId.contains("vulkan_zink") && !deviceHasOSMesaZinkBinary) continue;
if(rendererId.contains("ltw") && (!deviceHasOpenGLES3 || !appHasLtw)) continue;
rendererIds.add(rendererId);
rendererNames.add(defaultRendererNames[i]);
@@ -1413,4 +1733,141 @@ public static void dialogForceClose(Context ctx) {
}
}).show();
}
+
+ public static void switchDemo(boolean isDemo){
+ DIR_GAME_NEW = DIR_GAME_HOME + "/.minecraft";
+ DIR_HOME_VERSION = DIR_GAME_NEW + "/versions";
+ DIR_HOME_LIBRARY = DIR_GAME_NEW + "/libraries";
+ ASSETS_PATH = DIR_GAME_NEW + "/assets";
+ OBSOLETE_RESOURCES_PATH = DIR_GAME_NEW + "/resources";
+ }
+
+ private static NetworkInfo getActiveNetworkInfo(Context ctx) {
+ ConnectivityManager connMgr = (ConnectivityManager) ctx.getSystemService(Context.CONNECTIVITY_SERVICE);
+ NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();
+ return networkInfo; // This can return null when there is no wifi or data connected
+ }
+
+ public static boolean isOnline(Context ctx) {
+ NetworkInfo info = getActiveNetworkInfo(ctx);
+ if(info == null) return false;
+ return (info.isConnected());
+ }
+
+ public static boolean isDemoProfile(Context ctx){
+ return false;
+ }
+
+ public static boolean isLocalProfile(Context ctx){
+ MinecraftAccount currentProfile = PojavProfile.getCurrentProfileContent(ctx, null);
+ return currentProfile == null || currentProfile.isLocal();
+ }
+ public static boolean hasOnlineProfile(){
+ return true;
+ }
+
+ public static void hasNoOnlineProfileDialog(Activity activity, @Nullable Runnable run, @Nullable String customTitle, @Nullable String customMessage){
+ if (hasOnlineProfile() && !Tools.isDemoProfile(activity)){
+ if (run != null) { // Demo profile handling should be using customTitle and customMessage
+ run.run();
+ }
+ } else { // If there is no online profile, show a dialog
+ customTitle = customTitle == null ? activity.getString(R.string.no_minecraft_account_found) : customTitle;
+ customMessage = customMessage == null ? activity.getString(R.string.feature_requires_java_account) : customMessage;
+ dialogOnUiThread(activity, customTitle, customMessage);
+ }
+ }
+
+ // Some boilerplate to reduce boilerplate elsewhere
+ public static void hasNoOnlineProfileDialog(Activity activity){
+ hasNoOnlineProfileDialog(activity, null, null, null);
+ }
+ public static void hasNoOnlineProfileDialog(Activity activity, Runnable run){
+ hasNoOnlineProfileDialog(activity, run, null, null);
+ }
+ public static void hasNoOnlineProfileDialog(Activity activity, String customTitle, String customMessage){
+ hasNoOnlineProfileDialog(activity, null, customTitle, customMessage);
+ }
+
+ public static String getSelectedVanillaMcVer(){
+ String selectedProfile = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "");
+ MinecraftProfile selected = LauncherProfiles.mainProfileJson.profiles.get(selectedProfile);
+ if (selected == null) { // This should NEVER happen.
+ throw new RuntimeException("No profile selected, how did you reach this? Go ask in the discord or github");
+ }
+ String currentMCVersion = selected.lastVersionId;
+ String vanillaVersion = currentMCVersion;
+ File providedJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + currentMCVersion + "/" + currentMCVersion + ".json");
+ JMinecraftVersionList.Version providedJsonVersion = null;
+ try {
+ providedJsonVersion = Tools.GLOBAL_GSON.fromJson(Tools.read(providedJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class);
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ try {
+ vanillaVersion = providedJsonVersion.inheritsFrom != null ? providedJsonVersion.inheritsFrom : vanillaVersion;
+ } catch (NullPointerException e) {
+ throw new RuntimeException(e);
+ }
+ return vanillaVersion;
+ }
+
+ public static Integer mcVersiontoInt(String mcVersion){
+ String[] sVersionArray = mcVersion.split("\\.");
+ String[] iVersionArray = new String[3];
+ // Make sure this is actually a version string
+ for (int i = 0; i < iVersionArray.length; i++) {
+ try {
+ // Ensure there's padding
+ sVersionArray[i] = String.format("%3s", sVersionArray[i]).replace(' ', '0');
+ // Grab only the last 3, MCJE 999.999.999 isnt coming soon anyway
+ sVersionArray[i] = sVersionArray[i].substring(sVersionArray[i].length() - 3);
+ } catch (ArrayIndexOutOfBoundsException ignored){
+ // If we don't get 3 a third array, pad with 0s because it's probably 1.21 or something
+ iVersionArray[i] = "000";
+ continue;
+ }
+ try {
+ // Verify its a real deal, legit number
+ Integer.parseInt(sVersionArray[i]);
+ iVersionArray[i] = sVersionArray[i];
+ } catch (NumberFormatException e) {
+ throw new RuntimeException("Tools(mcVersiontoInt): Invalid version string");
+ }
+ }
+ return Integer.parseInt(iVersionArray[0] + iVersionArray[1] + iVersionArray[2]);
+ }
+
+ public static boolean isPointerDeviceConnected() {
+ int[] deviceIds = InputDevice.getDeviceIds();
+ for (int id : deviceIds) {
+ InputDevice device = InputDevice.getDevice(id);
+ if (device == null) continue;
+ int sources = device.getSources();
+ if ((sources & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE
+ || (sources & InputDevice.SOURCE_TOUCHPAD) == InputDevice.SOURCE_TOUCHPAD
+ || (sources & InputDevice.SOURCE_TRACKBALL) == InputDevice.SOURCE_TRACKBALL) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public static Object runMethodbyReflection(String className, String methodName) throws ReflectiveOperationException{
+ Class> clazz = Class.forName(className);
+ Method method = clazz.getDeclaredMethod(methodName);
+ method.setAccessible(true);
+ Object motionListener = method.invoke(null);
+ assert motionListener != null;
+ return motionListener;
+ }
+
+ static class SDL {
+ /**
+ * Initializes gamepad, joystick, and event subsystems.
+ * This triggers {@link SDLControllerManager#pollInputDevices()} and subsequently disables
+ * the emulated gamepad implementation.
+ */
+ public static native void initializeControllerSubsystems();
+ }
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java
new file mode 100644
index 0000000000..ee3431b806
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/AuthType.java
@@ -0,0 +1,48 @@
+package net.kdt.pojavlaunch.authenticator;
+
+import com.google.gson.annotations.SerializedName;
+
+import net.kdt.pojavlaunch.authenticator.impl.ElyByBackgroundLogin;
+import net.kdt.pojavlaunch.authenticator.impl.MicrosoftBackgroundLogin;
+
+import git.artdeell.mojo.R;
+
+public enum AuthType {
+ @SerializedName("microsoft")
+ MICROSOFT(
+ MicrosoftBackgroundLogin.CREATOR,
+ R.drawable.ic_auth_ms,
+ null,
+ "https://mineskin.eu/skin/%s" // Switched from mc-heads.net cause blocked in Russia
+ ),
+ @SerializedName("elyby")
+ ELY_BY(
+ ElyByBackgroundLogin.CREATOR,
+ R.drawable.ic_auth_elyby,
+ "ely.by",
+ "http://skinsystem.ely.by/skins/%s.png"
+ ),
+ @SerializedName("local")
+ LOCAL(null, 0, null, null);
+
+ private final BackgroundLogin.Creator mCreator;
+ public final int iconResource;
+ public final String injectorUrl;
+ public final String skinUrl;
+
+ AuthType(BackgroundLogin.Creator creator, int iconResource, String injectorUrl, String skinUrl) {
+ this.mCreator = creator;
+ this.iconResource = iconResource;
+ this.injectorUrl = injectorUrl;
+ this.skinUrl = skinUrl;
+ }
+
+ public boolean requiresLogin() {
+ return mCreator != null;
+ }
+
+ public BackgroundLogin createAuth() {
+ if(mCreator == null) throw new RuntimeException("This account does not support login");
+ return mCreator.create();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java
new file mode 100644
index 0000000000..e90781ffce
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/BackgroundLogin.java
@@ -0,0 +1,14 @@
+package net.kdt.pojavlaunch.authenticator;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.authenticator.listener.LoginListener;
+import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount;
+
+public interface BackgroundLogin {
+ void createAccount(@NonNull LoginListener loginListener, String code);
+ void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account);
+ interface Creator {
+ BackgroundLogin create();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java
new file mode 100644
index 0000000000..8e5d7ff948
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/Accounts.java
@@ -0,0 +1,119 @@
+package net.kdt.pojavlaunch.authenticator.accounts;
+
+import android.util.Log;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.authenticator.AuthType;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.FileUtils;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public class Accounts {
+ private static final String PROFILE_PREF_FILE = "selected_account_file";
+
+ public final List accounts;
+ public final int selectionIndex;
+
+ private Accounts(List accounts, int selectionIndex) {
+ this.accounts = accounts;
+ this.selectionIndex = selectionIndex;
+ }
+
+ public static Accounts load() throws IOException {
+ File accountsDir = new File(Tools.DIR_ACCOUNT_NEW);
+ synchronized (Accounts.class) {
+ FileUtils.ensureDirectory(accountsDir);
+ }
+ File[] accountFiles = accountsDir.listFiles();
+ if(accountFiles == null) throw new IOException("Failed to create account directory");
+ String selectedAccount = getSelectedAccount();
+ ArrayList accounts = new ArrayList<>(accountFiles.length);
+ int selectedAccountIdx = 0;
+ for(File accFile : accountFiles) {
+ MinecraftAccount account = loadAccount(accFile);
+ if(account == null) continue;
+ accounts.add(account);
+ if(accFile.getName().equals(selectedAccount)) {
+ selectedAccountIdx = accounts.size() - 1;
+ }
+ }
+ accounts.trimToSize();
+ return new Accounts(Collections.unmodifiableList(accounts), selectedAccountIdx);
+ }
+
+ private static MinecraftAccount loadAccount(File source) {
+ MinecraftAccount acc;
+ try {
+ acc = JSONUtils.readFromFile(source, MinecraftAccount.class);
+ }catch (Exception e) {
+ Log.w("Accounts", "Failed to load account", e);
+ return null;
+ }
+ if(acc == null) return null;
+ acc.mSaveLocation = source;
+
+ if (acc.accessToken == null) {
+ acc.accessToken = "0";
+ }
+ if (acc.profileId == null) {
+ acc.profileId = "00000000-0000-0000-0000-000000000000";
+ }
+ if (acc.username == null) {
+ acc.username = "0";
+ }
+ if (acc.refreshToken == null) {
+ acc.refreshToken = "0";
+ }
+ if(acc.authType == null) {
+ acc.authType = acc.isMicrosoft ? AuthType.MICROSOFT : AuthType.LOCAL;
+ }
+ return acc;
+ }
+
+ private static String getSelectedAccount() {
+ return LauncherPreferences.DEFAULT_PREF.getString(PROFILE_PREF_FILE, "");
+ }
+
+ public static MinecraftAccount getCurrent() {
+ String selectedAccount = getSelectedAccount();
+ return loadAccount(new File(Tools.DIR_ACCOUNT_NEW, selectedAccount));
+ }
+
+ private static File pickAccountPath() {
+ File profilePath;
+ do {
+ String profileName = UUID.randomUUID().toString();
+ profilePath = new File(Tools.DIR_ACCOUNT_NEW, profileName);
+ } while(profilePath.exists());
+ return profilePath;
+ }
+
+ public static MinecraftAccount create(Setter setter) throws IOException {
+ MinecraftAccount minecraftAccount = new MinecraftAccount();
+ setter.writeAccount(minecraftAccount);
+ minecraftAccount.mSaveLocation = pickAccountPath();
+ minecraftAccount.save();
+ return minecraftAccount;
+ }
+
+ public static void setCurrent(MinecraftAccount minecraftAccount) {
+ LauncherPreferences.DEFAULT_PREF
+ .edit().putString(PROFILE_PREF_FILE, minecraftAccount.mSaveLocation.getName())
+ .apply();
+ }
+
+ public static void delete(MinecraftAccount minecraftAccount) {
+ boolean ignored = minecraftAccount.mSaveLocation.delete();
+ }
+
+ public interface Setter {
+ void writeAccount(MinecraftAccount minecraftAccount) throws IOException;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java
new file mode 100644
index 0000000000..351cbc7539
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/MinecraftAccount.java
@@ -0,0 +1,96 @@
+package net.kdt.pojavlaunch.authenticator.accounts;
+
+
+import android.graphics.BitmapFactory;
+import android.util.Log;
+
+import net.kdt.pojavlaunch.*;
+import net.kdt.pojavlaunch.authenticator.AuthType;
+import net.kdt.pojavlaunch.utils.FileUtils;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+
+import java.io.*;
+import java.net.URL;
+
+import android.graphics.Bitmap;
+
+import androidx.annotation.Keep;
+
+import com.google.gson.JsonParseException;
+
+import org.apache.commons.io.IOUtils;
+
+@Keep
+public class MinecraftAccount {
+ public transient File mSaveLocation;
+ public String accessToken = "0"; // access token
+ public String profileId = "00000000-0000-0000-0000-000000000000"; // profile UUID, for obtaining skin
+ public String username = "Steve";
+ public AuthType authType = AuthType.LOCAL;
+ public boolean isMicrosoft = false;
+ public String refreshToken = "0";
+ public String xuid;
+ public long expiresAt;
+ private transient Bitmap mFaceCache;
+
+ protected MinecraftAccount() {}
+
+ public void updateSkinFace() {
+ String skinFaceUrlTemplate = authType.skinUrl;
+ if(skinFaceUrlTemplate == null) return;
+ String skinFaceUrl = String.format(skinFaceUrlTemplate, username);
+ try {
+ Log.i("SkinLoader", "Updating skin face...");
+ File skinFile = getSkinFaceFile();
+ // Streaming it directly breaks on some devices
+ byte[] skinBytes = IOUtils.toByteArray(new URL(skinFaceUrl));
+ Bitmap skinBitmap = BitmapFactory.decodeByteArray(skinBytes, 0, skinBytes.length);
+ if(skinBitmap == null) return;
+ Bitmap skinFace = new SkinHeadRenderer().render(100, skinBitmap);
+ skinBitmap.recycle();
+ if(skinFace == null) return;
+ try(FileOutputStream fileOutputStream = new FileOutputStream(skinFile)) {
+ skinFace.compress(Bitmap.CompressFormat.WEBP, 90, fileOutputStream);
+ }
+ Log.i("SkinLoader", "Update skin face success");
+ } catch (IOException e) {
+ // Skin refresh limit, no internet connection, etc...
+ // Simply ignore updating skin face
+ Log.w("SkinLoader", "Could not update skin face", e);
+ }
+ }
+
+ public boolean isLocal(){
+ return accessToken.equals("0");
+ }
+
+ public void save() throws IOException {
+ FileUtils.ensureParentDirectory(mSaveLocation);
+ JSONUtils.writeToFile(mSaveLocation, this);
+ }
+
+ public MinecraftAccount reload() {
+ try {
+ MinecraftAccount minecraftAccount = JSONUtils.readFromFile(mSaveLocation, MinecraftAccount.class);
+ if(minecraftAccount == null) return null;
+ minecraftAccount.mSaveLocation = mSaveLocation;
+ return minecraftAccount;
+ }catch (IOException | JsonParseException e) {
+ return null;
+ }
+ }
+
+ public Bitmap getSkinFace(){
+ if(isLocal()) return null;
+ File skinFaceFile = getSkinFaceFile();
+ if(!skinFaceFile.exists()) return null;
+ if(mFaceCache == null) {
+ mFaceCache = BitmapFactory.decodeFile(skinFaceFile.getAbsolutePath());
+ }
+ return mFaceCache;
+ }
+
+ private File getSkinFaceFile() {
+ return new File(Tools.DIR_CACHE, "skin-face-" + profileId +"-"+authType.name() + ".webp");
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java
new file mode 100644
index 0000000000..82f7c25b5a
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/accounts/SkinHeadRenderer.java
@@ -0,0 +1,182 @@
+package net.kdt.pojavlaunch.authenticator.accounts;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Matrix;
+import android.util.Log;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public class SkinHeadRenderer {
+ private static final Matrix MIRROR_MATRIX = new Matrix();
+ static {
+ MIRROR_MATRIX.setScale(-1, 1);
+ }
+
+ // Points of an isometric cube
+ private static final float[][] ISO_POINTS = {
+ {0.5f, 1.0f }, // 0 Bottom-most point
+ {0.9330127f, 0.75f}, // 1 Bottom right point
+ {0.066987306f, 0.75f}, // 2 Bottom left point
+ {0.5f, 0.0f }, // 3 Topmost point
+ {0.9330127f, 0.25f}, // 4 Top right point
+ {0.066987306f, 0.25f}, // 5 Top left point
+ {0.5f, 0.5f } // 6 Center point
+ };
+
+ // Faces of an isometric cube
+ private static final int FACE_LEFT = 0;
+ private static final int FACE_RIGHT = 1;
+ private static final int FACE_TOP = 2;
+ private static final int FACE_REAR_RIGHT = 3;
+ private static final int FACE_REAR_LEFT = 4;
+ private static final int FACE_BOTTOM = 5;
+
+ private int mCoordScale = 1;
+ private final float[] mMeshBuffer = new float[8];
+ private final Set mTempBitmaps = new HashSet<>();
+
+ private Bitmap getSubregion(Bitmap src, int left, int top, int right, int bottom) {
+ return getSubregion(src, left, top, right, bottom, false);
+ }
+
+ private Bitmap internalGetSubregion(Bitmap src, int left, int top, int right, int bottom, boolean mirror) {
+ Bitmap bitmap = Bitmap.createBitmap(src, left, top, right - left, bottom - top);
+ if(!mirror) return bitmap;
+ Bitmap mirroredBitmap = Bitmap.createBitmap(
+ bitmap,
+ 0, 0,
+ bitmap.getWidth(), bitmap.getHeight(),
+ MIRROR_MATRIX, false
+ );
+ bitmap.recycle();
+ return mirroredBitmap;
+ }
+
+ private Bitmap getSubregion(Bitmap src, int left, int top, int right, int bottom, boolean mirror) {
+ // Provision for HD skins: scale regular skin coordinate inputs
+ left *= mCoordScale;
+ top *= mCoordScale;
+ right *= mCoordScale;
+ bottom *= mCoordScale;
+
+ Bitmap subregion = internalGetSubregion(src, left, top, right, bottom, mirror);
+ mTempBitmaps.add(subregion);
+
+ return subregion;
+ }
+
+ /**
+ * Write one face of an isometric cube as mesh points suitable for drawBitmapMesh()
+ * @param dst destination array for mesh (should have length of 8)
+ * @param face the face to write (one of the face constants)
+ * @param mul the amount by how much each point should be multiplied
+ * @param off the amount by how much each point should be offset to the right
+ */
+ private void applyFace(float[] dst, int face, float mul, float off) {
+ switch (face) {
+ case FACE_LEFT:
+ dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off;
+ dst[2] = ISO_POINTS[6][0] * mul + off; dst[3] = ISO_POINTS[6][1] * mul + off;
+ dst[4] = ISO_POINTS[2][0] * mul + off; dst[5] = ISO_POINTS[2][1] * mul + off;
+ dst[6] = ISO_POINTS[0][0] * mul + off; dst[7] = ISO_POINTS[0][1] * mul + off;
+ return;
+ case FACE_RIGHT:
+ dst[0] = ISO_POINTS[6][0] * mul + off; dst[1] = ISO_POINTS[6][1] * mul + off;
+ dst[2] = ISO_POINTS[4][0] * mul + off; dst[3] = ISO_POINTS[4][1] * mul + off;
+ dst[4] = ISO_POINTS[0][0] * mul + off; dst[5] = ISO_POINTS[0][1] * mul + off;
+ dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off;
+ return;
+ case FACE_TOP:
+ dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off;
+ dst[2] = ISO_POINTS[3][0] * mul + off; dst[3] = ISO_POINTS[3][1] * mul + off;
+ dst[4] = ISO_POINTS[6][0] * mul + off; dst[5] = ISO_POINTS[6][1] * mul + off;
+ dst[6] = ISO_POINTS[4][0] * mul + off; dst[7] = ISO_POINTS[4][1] * mul + off;
+ return;
+ case FACE_REAR_RIGHT:
+ dst[0] = ISO_POINTS[3][0] * mul + off; dst[1] = ISO_POINTS[3][1] * mul + off;
+ dst[2] = ISO_POINTS[4][0] * mul + off; dst[3] = ISO_POINTS[4][1] * mul + off;
+ dst[4] = ISO_POINTS[6][0] * mul + off; dst[5] = ISO_POINTS[6][1] * mul + off;
+ dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off;
+ return;
+ case FACE_REAR_LEFT:
+ dst[0] = ISO_POINTS[5][0] * mul + off; dst[1] = ISO_POINTS[5][1] * mul + off;
+ dst[2] = ISO_POINTS[3][0] * mul + off; dst[3] = ISO_POINTS[3][1] * mul + off;
+ dst[4] = ISO_POINTS[2][0] * mul + off; dst[5] = ISO_POINTS[2][1] * mul + off;
+ dst[6] = ISO_POINTS[6][0] * mul + off; dst[7] = ISO_POINTS[6][1] * mul + off;
+ return;
+ case FACE_BOTTOM:
+ dst[0] = ISO_POINTS[2][0] * mul + off; dst[1] = ISO_POINTS[2][1] * mul + off;
+ dst[2] = ISO_POINTS[6][0] * mul + off; dst[3] = ISO_POINTS[6][1] * mul + off;
+ dst[4] = ISO_POINTS[0][0] * mul + off; dst[5] = ISO_POINTS[0][1] * mul + off;
+ dst[6] = ISO_POINTS[1][0] * mul + off; dst[7] = ISO_POINTS[1][1] * mul + off;
+ }
+ }
+
+ private void drawMesh(Canvas canvas, Bitmap bitmap, int face, float multiplier, float offset) {
+ applyFace(mMeshBuffer, face, multiplier, offset);
+ canvas.drawBitmapMesh(bitmap,
+ 1, 1, mMeshBuffer,
+ 0, null, 0,
+ null
+ );
+ }
+
+ private boolean prepareCoordScale(int sourceDimension) {
+ if(sourceDimension % 64 != 0) {
+ Log.e("SkinHeadRenderer", "Invalid skin dimension: "+ sourceDimension);
+ return false;
+ }
+ mCoordScale = sourceDimension / 64;
+ return true;
+ }
+
+ public Bitmap render(int side, Bitmap sourceSkin) {
+ if(!prepareCoordScale(sourceSkin.getWidth())) return null;
+
+ // Bitmap overlay regions
+ Bitmap overlayTopFace = getSubregion(sourceSkin, 40, 0, 48,8);
+ Bitmap overlayLeftFace = getSubregion(sourceSkin, 32, 8, 40,16);
+ Bitmap overlayRightFace = getSubregion(sourceSkin, 40, 8, 48,16);
+ Bitmap overlayBottomFace = getSubregion(sourceSkin, 48, 0, 56, 8);
+ Bitmap overlayRearLeftFace = getSubregion(sourceSkin, 56, 8, 64, 16, true);
+ Bitmap overlayRearRightFace = getSubregion(sourceSkin, 48, 8, 56, 16, true);
+
+ // Bitmap head regions
+ Bitmap topFace = getSubregion(sourceSkin, 8,0,16,8);
+ Bitmap leftFace = getSubregion(sourceSkin, 0,8,8,16);
+ Bitmap rightFace = getSubregion(sourceSkin, 8,8,16,16);
+
+ Bitmap renderTarget = Bitmap.createBitmap(side, side, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(renderTarget);
+
+ float multiplier = 1f * side;
+
+ // The head should be slightly smaller than the accessory overlay around it,
+ // and should appear to be in the middle of the accessory overlay.
+ float headOffset = multiplier / 16f;
+ float headMultiplier = multiplier * 14f/16f;
+
+ // Rear side of overlay layer
+ drawMesh(canvas, overlayRearLeftFace, FACE_REAR_LEFT, multiplier, 0);
+ drawMesh(canvas, overlayRearRightFace, FACE_REAR_RIGHT, multiplier, 0);
+ drawMesh(canvas, overlayBottomFace, FACE_BOTTOM, multiplier, 0);
+
+ // Player head
+ drawMesh(canvas, leftFace, FACE_LEFT, headMultiplier, headOffset);
+ drawMesh(canvas, rightFace, FACE_RIGHT, headMultiplier, headOffset);
+ drawMesh(canvas, topFace, FACE_TOP, headMultiplier, headOffset);
+
+ // Front side of the overlay layer
+ drawMesh(canvas, overlayLeftFace, FACE_LEFT, multiplier, 0);
+ drawMesh(canvas, overlayRightFace, FACE_RIGHT, multiplier, 0);
+ drawMesh(canvas, overlayTopFace, FACE_TOP, multiplier, 0);
+
+ // Free all regions
+ for(Bitmap region : mTempBitmaps) region.recycle();
+ mTempBitmaps.clear();
+ // Done!
+ return renderTarget;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java
new file mode 100644
index 0000000000..e292ec405e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/CommonLoginUtils.java
@@ -0,0 +1,68 @@
+package net.kdt.pojavlaunch.authenticator.impl;
+
+import android.util.Log;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+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 git.artdeell.mojo.R;
+
+public class CommonLoginUtils {
+
+ public static OAuthTokenResponse exchangeAuthCode(URL url, String formData) throws IOException {
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded");
+ conn.setRequestProperty("charset", "utf-8");
+ conn.setRequestProperty("Content-Length", Integer.toString(formData.getBytes(StandardCharsets.UTF_8).length));
+ conn.setRequestMethod("POST");
+ conn.setUseCaches(false);
+ conn.setDoInput(true);
+ conn.setDoOutput(true);
+ conn.connect();
+ try(OutputStream wr = conn.getOutputStream()) {
+ wr.write(formData.getBytes(StandardCharsets.UTF_8));
+ }
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) {
+ return Tools.GLOBAL_GSON.fromJson(reader, OAuthTokenResponse.class);
+ } finally {
+ conn.disconnect();
+ }
+ }else{
+ Log.i("CommonLogin", "Auth fail: "+Tools.read(conn.getErrorStream()));
+ throw getResponseThrowable(conn);
+ }
+ }
+
+ /**
+ * @param data A series a strings: key1, value1, key2, value2...
+ * @return the data converted as a form string for a POST request
+ */
+ public static String convertToFormData(String... data) throws UnsupportedEncodingException {
+ StringBuilder builder = new StringBuilder();
+ for(int i=0; i 0) builder.append("&");
+ builder.append(URLEncoder.encode(data[i], "UTF-8"))
+ .append("=")
+ .append(URLEncoder.encode(data[i+1], "UTF-8"));
+ }
+ return builder.toString();
+ }
+
+ public static RuntimeException getResponseThrowable(HttpURLConnection conn) throws IOException {
+ Log.i("MicrosoftLogin", "Error code: " + conn.getResponseCode() + ": " + conn.getResponseMessage());
+ if(conn.getResponseCode() == 429) {
+ return new PresentedException(R.string.microsoft_login_retry_later);
+ }
+ return new RuntimeException(conn.getResponseMessage());
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java
new file mode 100644
index 0000000000..058e7aafb7
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/ElyByBackgroundLogin.java
@@ -0,0 +1,129 @@
+package net.kdt.pojavlaunch.authenticator.impl;
+
+import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
+
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.authenticator.AuthType;
+import net.kdt.pojavlaunch.authenticator.BackgroundLogin;
+import net.kdt.pojavlaunch.authenticator.accounts.Accounts;
+import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount;
+import net.kdt.pojavlaunch.authenticator.listener.LoginListener;
+import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.concurrent.Callable;
+
+public class ElyByBackgroundLogin implements BackgroundLogin {
+ public static final BackgroundLogin.Creator CREATOR = ElyByBackgroundLogin::new;
+
+ private static final String authTokenUrl = "https://account.ely.by/api/oauth2/v1/token";
+ private static final String accountInfoUrl = "https://account.ely.by/api/account/v1/info";
+
+ private OAuthTokenResponse mOAuthData;
+ private ElyAccountInfo mAccountInfo;
+ private long mExpiresAt;
+
+ private ElyByBackgroundLogin() {}
+
+ private void acquireAccountDetails(
+ @NonNull LoginListener loginListener, Callable continuation,
+ String code, boolean isRefresh
+ ) {
+ ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0);
+ sExecutorService.execute(() -> {
+ loginListener.setMaxLoginProgress(2);
+ try {
+ notifyProgress(loginListener, 1);
+ acquireTokens(isRefresh, code);
+ notifyProgress(loginListener, 2);
+ mAccountInfo = acquireAccountData(mOAuthData.accessToken);
+ continuation.call();
+ }catch (Exception e){
+ Log.e("MicroAuth", "Exception thrown during authentication", e);
+ Tools.runOnUiThread(()->loginListener.onLoginError(e));
+ }
+ ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE);
+ });
+ }
+
+ private void fillAccount(MinecraftAccount acc) {
+ acc.expiresAt = mExpiresAt;
+ acc.authType = AuthType.ELY_BY;
+ acc.accessToken = mOAuthData.accessToken;
+ acc.refreshToken = mOAuthData.refreshToken;
+ acc.username = mAccountInfo.username;
+ acc.profileId = mAccountInfo.uuid;
+ acc.xuid = null;
+ acc.updateSkinFace();
+ }
+
+ @Override
+ public void createAccount(@NonNull LoginListener loginListener, String code) {
+ acquireAccountDetails(loginListener, ()->{
+ MinecraftAccount account = Accounts.create(this::fillAccount);
+ Tools.runOnUiThread(() -> loginListener.onLoginDone(account));
+ return null;
+ }, code, false);
+ }
+
+ @Override
+ public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) {
+ acquireAccountDetails(loginListener, ()->{
+ fillAccount(account);
+ account.save();
+ Tools.runOnUiThread(() -> loginListener.onLoginDone(account));
+ return null;
+ }, account.refreshToken, true);
+ }
+
+ private void acquireTokens(boolean isRefresh, String code) throws IOException {
+ URL url = new URL(authTokenUrl);
+ Log.i("MicrosoftLogin", "isRefresh=" + isRefresh + ", authCode= "+code);
+
+ String formData = CommonLoginUtils.convertToFormData(
+ "client_id", "mojolauncher2",
+ "client_secret", "o14Zb2Zzj0_k6o4kN0t1mIEhoQxeayn8hYi5VSX2q3NXrdQm5T2Q6wqsCfpv1vhu",
+ "redirect_uri", "internalredirect://complete",
+ isRefresh ? "refresh_token" : "code", code,
+ "grant_type", isRefresh ? "refresh_token" : "authorization_code"
+ );
+ mOAuthData = CommonLoginUtils.exchangeAuthCode(url, formData);
+ mExpiresAt = mOAuthData.expiresIn*1000 + System.currentTimeMillis();
+ }
+
+ private ElyAccountInfo acquireAccountData(String accessToken) throws IOException {
+ URL url = new URL(accountInfoUrl);
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestProperty("Authorization", "Bearer " + accessToken);
+ conn.setUseCaches(false);
+ conn.connect();
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ try (InputStreamReader reader = new InputStreamReader(conn.getInputStream())) {
+ return Tools.GLOBAL_GSON.fromJson(reader, ElyAccountInfo.class);
+ } finally {
+ conn.disconnect();
+ }
+ }else{
+ throw CommonLoginUtils.getResponseThrowable(conn);
+ }
+ }
+
+ private void notifyProgress(LoginListener listener, int step){
+ Tools.runOnUiThread(() -> listener.onLoginProgress(step));
+ ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step*50);
+ }
+
+ private static class ElyAccountInfo {
+ public String uuid;
+ public String username;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java
new file mode 100644
index 0000000000..22c083b7a9
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/MicrosoftBackgroundLogin.java
@@ -0,0 +1,316 @@
+package net.kdt.pojavlaunch.authenticator.impl;
+
+import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
+
+import android.util.ArrayMap;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import git.artdeell.mojo.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.authenticator.AuthType;
+import net.kdt.pojavlaunch.authenticator.BackgroundLogin;
+import net.kdt.pojavlaunch.authenticator.accounts.Accounts;
+import net.kdt.pojavlaunch.authenticator.listener.LoginListener;
+import net.kdt.pojavlaunch.authenticator.model.OAuthTokenResponse;
+import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.ProtocolException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.Callable;
+
+/** Allow to perform a background login on a given account */
+public class MicrosoftBackgroundLogin implements BackgroundLogin{
+ public static final BackgroundLogin.Creator CREATOR = MicrosoftBackgroundLogin::new;
+
+ private static final String authTokenUrl = "https://login.live.com/oauth20_token.srf";
+ private static final String xblAuthUrl = "https://user.auth.xboxlive.com/user/authenticate";
+ private static final String xstsAuthUrl = "https://xsts.auth.xboxlive.com/xsts/authorize";
+ private static final String mcLoginUrl = "https://api.minecraftservices.com/authentication/login_with_xbox";
+ private static final String mcProfileUrl = "https://api.minecraftservices.com/minecraft/profile";
+ private static final String mcStoreUrl = "https://api.minecraftservices.com/entitlements/mcstore";
+
+ private static final Map XSTS_ERRORS;
+ static {
+ XSTS_ERRORS = new ArrayMap<>();
+ XSTS_ERRORS.put(2148916233L, R.string.xerr_no_account);
+ XSTS_ERRORS.put(2148916235L, R.string.xerr_not_available);
+ XSTS_ERRORS.put(2148916236L ,R.string.xerr_adult_verification);
+ XSTS_ERRORS.put(2148916237L ,R.string.xerr_adult_verification);
+ XSTS_ERRORS.put(2148916238L ,R.string.xerr_child);
+ }
+
+ /* Fields used to fill the account */
+ public String msRefreshToken;
+ public String mcName;
+ public String mcToken;
+ public String mcUuid;
+ public String msXsts;
+ public boolean doesOwnGame;
+ public long expiresAt;
+
+ private MicrosoftBackgroundLogin() {}
+
+ private void acquireAccountDetails(
+ @NonNull LoginListener loginListener, Callable continuation,
+ String code, boolean isRefresh
+ ) {
+ ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, 0);
+ sExecutorService.execute(() -> {
+ loginListener.setMaxLoginProgress(5);
+ try {
+ notifyProgress(loginListener, 1);
+ String accessToken = acquireAccessToken(isRefresh, code);
+ notifyProgress(loginListener, 2);
+ String xboxLiveToken = acquireXBLToken(accessToken);
+ notifyProgress(loginListener, 3);
+ String[] xsts = acquireXsts(xboxLiveToken);
+ notifyProgress(loginListener, 4);
+ String mcToken = acquireMinecraftToken(xsts[0], xsts[1]);
+ notifyProgress(loginListener, 5);
+ fetchOwnedItems(mcToken);
+ checkMcProfile(mcToken);
+ msXsts = xsts[0];
+ continuation.call();
+ }catch (Exception e){
+ Log.e("MicroAuth", "Exception thrown during authentication", e);
+ Tools.runOnUiThread(()->loginListener.onLoginError(e));
+ } finally {
+ ProgressLayout.clearProgress(ProgressLayout.AUTHENTICATE);
+ }
+ });
+ }
+
+ private void fillAccount(MinecraftAccount acc) {
+ acc.xuid = msXsts;
+ acc.accessToken = mcToken;
+ acc.username = mcName;
+ acc.profileId = mcUuid;
+ acc.authType = AuthType.MICROSOFT;
+ acc.refreshToken = msRefreshToken;
+ acc.expiresAt = expiresAt;
+ acc.updateSkinFace();
+ }
+
+ @Override
+ public void createAccount(@NonNull LoginListener loginListener, String code) {
+ acquireAccountDetails(loginListener, ()->{
+ MinecraftAccount account = Accounts.create(this::fillAccount);
+ Tools.runOnUiThread(() -> loginListener.onLoginDone(account));
+ return null;
+ }, code, false);
+ }
+
+ @Override
+ public void refreshAccount(@NonNull LoginListener loginListener, MinecraftAccount account) {
+ acquireAccountDetails(loginListener, ()->{
+ if(doesOwnGame) fillAccount(account);
+ account.save();
+ Tools.runOnUiThread(() -> loginListener.onLoginDone(account));
+ return null;
+ }, account.refreshToken, true);
+ }
+
+ private String acquireAccessToken(boolean isRefresh, String code) throws IOException {
+ URL url = new URL(authTokenUrl);
+ Log.i("MicrosoftLogin", "isRefresh=" + isRefresh + ", authCode= "+code);
+
+ String formData = CommonLoginUtils.convertToFormData(
+ "client_id", "00000000402b5328",
+ isRefresh ? "refresh_token" : "code", code,
+ "grant_type", isRefresh ? "refresh_token" : "authorization_code",
+ "redirect_url", "https://login.live.com/oauth20_desktop.srf",
+ "scope", "service::user.auth.xboxlive.com::MBI_SSL"
+ );
+
+ OAuthTokenResponse response = CommonLoginUtils.exchangeAuthCode(url, formData);
+ msRefreshToken = response.refreshToken;
+ return response.accessToken;
+ }
+
+ private String acquireXBLToken(String accessToken) throws IOException, JSONException {
+ URL url = new URL(xblAuthUrl);
+
+ JSONObject data = new JSONObject();
+ JSONObject properties = new JSONObject();
+ properties.put("AuthMethod", "RPS");
+ properties.put("SiteName", "user.auth.xboxlive.com");
+ properties.put("RpsTicket", accessToken);
+ data.put("Properties",properties);
+ data.put("RelyingParty", "http://auth.xboxlive.com");
+ data.put("TokenType", "JWT");
+
+ String req = data.toString();
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ setCommonProperties(conn, req);
+ conn.connect();
+
+ try(OutputStream wr = conn.getOutputStream()) {
+ wr.write(req.getBytes(StandardCharsets.UTF_8));
+ }
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ JSONObject jo = new JSONObject(Tools.read(conn.getInputStream()));
+ conn.disconnect();
+ Log.i("MicrosoftLogin","Xbl Token = "+jo.getString("Token"));
+ return jo.getString("Token");
+ //acquireXsts(jo.getString("Token"));
+ }else{
+ throw CommonLoginUtils.getResponseThrowable(conn);
+ }
+ }
+
+ /** @return [uhs, token]*/
+ private @NonNull String[] acquireXsts(String xblToken) throws IOException, JSONException {
+ URL url = new URL(xstsAuthUrl);
+
+ JSONObject data = new JSONObject();
+ JSONObject properties = new JSONObject();
+ properties.put("SandboxId", "RETAIL");
+ properties.put("UserTokens", new JSONArray(Collections.singleton(xblToken)));
+ data.put("Properties", properties);
+ data.put("RelyingParty", "rp://api.minecraftservices.com/");
+ data.put("TokenType", "JWT");
+
+ String req = data.toString();
+ Log.i("MicroAuth", req);
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ setCommonProperties(conn, req);
+ Log.i("MicroAuth", conn.getRequestMethod());
+ conn.connect();
+
+ try(OutputStream wr = conn.getOutputStream()) {
+ wr.write(req.getBytes(StandardCharsets.UTF_8));
+ }
+
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ JSONObject jo = new JSONObject(Tools.read(conn.getInputStream()));
+ String uhs = jo.getJSONObject("DisplayClaims").getJSONArray("xui").getJSONObject(0).getString("uhs");
+ String token = jo.getString("Token");
+ conn.disconnect();
+ Log.i("MicrosoftLogin","Xbl Xsts = " + token + "; Uhs = " + uhs);
+ return new String[]{uhs, token};
+ //acquireMinecraftToken(uhs,jo.getString("Token"));
+ }else if(conn.getResponseCode() == 401) {
+ String responseContents = Tools.read(conn.getErrorStream());
+ JSONObject jo = new JSONObject(responseContents);
+ long xerr = jo.optLong("XErr", -1);
+ Integer locale_id = XSTS_ERRORS.get(xerr);
+ if(locale_id != null) {
+ throw new PresentedException(new RuntimeException(responseContents), locale_id);
+ }
+ throw new PresentedException(new RuntimeException(responseContents), R.string.xerr_unknown, xerr);
+ }else{
+ throw CommonLoginUtils.getResponseThrowable(conn);
+ }
+ }
+
+ private String acquireMinecraftToken(String xblUhs, String xblXsts) throws IOException, JSONException {
+ URL url = new URL(mcLoginUrl);
+
+ JSONObject data = new JSONObject();
+ data.put("identityToken", "XBL3.0 x=" + xblUhs + ";" + xblXsts);
+
+ String req = data.toString();
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ setCommonProperties(conn, req);
+ conn.connect();
+
+ try(OutputStream wr = conn.getOutputStream()) {
+ wr.write(req.getBytes(StandardCharsets.UTF_8));
+ }
+
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ expiresAt = System.currentTimeMillis() + 86400000;
+ JSONObject jo = new JSONObject(Tools.read(conn.getInputStream()));
+ conn.disconnect();
+ Log.i("MicrosoftLogin","MC token: "+jo.getString("access_token"));
+ mcToken = jo.getString("access_token");
+ //checkMcProfile(jo.getString("access_token"));
+ return jo.getString("access_token");
+ }else{
+ throw CommonLoginUtils.getResponseThrowable(conn);
+ }
+ }
+
+ private void fetchOwnedItems(String mcAccessToken) throws IOException {
+ URL url = new URL(mcStoreUrl);
+
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestProperty("Authorization", "Bearer " + mcAccessToken);
+ conn.setUseCaches(false);
+ conn.connect();
+ if(conn.getResponseCode() < 200 || conn.getResponseCode() >= 300) {
+ throw CommonLoginUtils.getResponseThrowable(conn);
+ }
+ // We don't need any data from this request, it just needs to happen in order for
+ // the MS servers to work properly. The data from this is practically useless
+ // as it does not indicate whether the user owns the game through Game Pass.
+ }
+
+ private void checkMcProfile(String mcAccessToken) throws IOException, JSONException {
+ URL url = new URL(mcProfileUrl);
+
+ HttpURLConnection conn = (HttpURLConnection)url.openConnection();
+ conn.setRequestProperty("Authorization", "Bearer " + mcAccessToken);
+ conn.setUseCaches(false);
+ conn.connect();
+
+ if(conn.getResponseCode() >= 200 && conn.getResponseCode() < 300) {
+ String s= Tools.read(conn.getInputStream());
+ conn.disconnect();
+ Log.i("MicrosoftLogin","profile:" + s);
+ JSONObject jsonObject = new JSONObject(s);
+ String name = (String) jsonObject.get("name");
+ String uuid = (String) jsonObject.get("id");
+ String uuidDashes = uuid.replaceFirst(
+ "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", "$1-$2-$3-$4-$5"
+ );
+ doesOwnGame = true;
+ Log.i("MicrosoftLogin","UserName = " + name);
+ Log.i("MicrosoftLogin","Uuid Minecraft = " + uuidDashes);
+ mcName=name;
+ mcUuid=uuidDashes;
+ }else{
+ Log.i("MicrosoftLogin","It seems that this Microsoft Account does not own the game.");
+ doesOwnGame = false;
+ throw new PresentedException(new RuntimeException(conn.getResponseMessage()), R.string.minecraft_not_owned);
+ //throwResponseError(conn);
+ }
+ }
+
+ /** Wrapper to ease notifying the listener */
+ private void notifyProgress(LoginListener listener, int step){
+ Tools.runOnUiThread(() -> listener.onLoginProgress(step));
+ ProgressLayout.setProgress(ProgressLayout.AUTHENTICATE, step*20);
+ }
+
+
+ /** Set common properties for the connection. Given that all requests are POST, interactivity is always enabled */
+ private static void setCommonProperties(HttpURLConnection conn, String formData) {
+ conn.setRequestProperty("Content-Type", "application/json");
+ conn.setRequestProperty("Accept", "application/json");
+ conn.setRequestProperty("charset", "utf-8");
+ try {
+ conn.setRequestProperty("Content-Length", Integer.toString(formData.getBytes(StandardCharsets.UTF_8).length));
+ conn.setRequestMethod("POST");
+ }catch (ProtocolException e) {
+ Log.e("MicrosoftAuth", e.toString());
+ }
+ conn.setUseCaches(false);
+ conn.setDoInput(true);
+ conn.setDoOutput(true);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java
new file mode 100644
index 0000000000..a6bed8ca4b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/impl/PresentedException.java
@@ -0,0 +1,23 @@
+package net.kdt.pojavlaunch.authenticator.impl;
+
+import android.content.Context;
+
+public class PresentedException extends RuntimeException {
+ final int localizationStringId;
+ final Object[] extraArgs;
+
+ public PresentedException(int localizationStringId, Object... extraArgs) {
+ this.localizationStringId = localizationStringId;
+ this.extraArgs = extraArgs;
+ }
+
+ public PresentedException(Throwable throwable, int localizationStringId, Object... extraArgs) {
+ super(throwable);
+ this.localizationStringId = localizationStringId;
+ this.extraArgs = extraArgs;
+ }
+
+ public String toString(Context context) {
+ return context.getString(localizationStringId, extraArgs);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java
new file mode 100644
index 0000000000..aec19d82bf
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/listener/LoginListener.java
@@ -0,0 +1,10 @@
+package net.kdt.pojavlaunch.authenticator.listener;
+
+import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount;
+
+public interface LoginListener{
+ void onLoginDone(MinecraftAccount account);
+ void onLoginError(Throwable errorMessage);
+ void onLoginProgress(int step);
+ void setMaxLoginProgress(int max);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java
new file mode 100644
index 0000000000..72ba85f524
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/authenticator/model/OAuthTokenResponse.java
@@ -0,0 +1,9 @@
+package net.kdt.pojavlaunch.authenticator.model;
+
+import com.google.gson.annotations.SerializedName;
+
+public class OAuthTokenResponse {
+ @SerializedName("access_token") public String accessToken;
+ @SerializedName("refresh_token") public String refreshToken;
+ @SerializedName("expires_in") public long expiresIn;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java
index 8b011a9040..8bd5e4547e 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/contracts/OpenDocumentWithExtension.java
@@ -10,10 +10,14 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import java.util.ArrayList;
+import java.util.Objects;
+
// Android's OpenDocument contract is the basicmost crap that doesn't allow
// you to specify practically anything. So i made this instead.
public class OpenDocumentWithExtension extends ActivityResultContract {
private final String mimeType;
+ private final String[] mimeTypes;
/**
* Create a new OpenDocumentWithExtension contract.
@@ -25,6 +29,37 @@ public OpenDocumentWithExtension(String extension) {
String extensionMimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
if(extensionMimeType == null) extensionMimeType = "*/*";
mimeType = extensionMimeType;
+ mimeTypes = null;
+ }
+ public OpenDocumentWithExtension(String[] extensions) {
+ ArrayList extensionsMimeType = new ArrayList<>();
+ int count = 0;
+ for (String extension: extensions) {
+ String extensionMimetype = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension);
+ if (Objects.equals(extension, "mrpack")) {
+ // Special handling here because depending on whether the ROM has
+ // `x-modrinth-modpack+zip` in their mimetypes or not because if it does,
+ // `octet-stream` will no longer match mrpack files.
+
+ // Checking this with MimeTypeMap.hasExtension() and .hasMimeType() always returns
+ // false so we do both instead.
+
+ // `octet-stream` highlights a lot of unrelated files but it's the best
+ // we can do. Mimetypes are built into the ROM after all.
+ // See https://android.googlesource.com/platform/external/mime-support/+/refs/heads/main
+ // or https://android.googlesource.com/platform/frameworks/base/+/refs/heads/main/mime/java-res/android.mime.types
+ extensionsMimeType.add("application/octet-stream");
+ extensionsMimeType.add("application/x-modrinth-modpack+zip");
+ count++;
+ continue;
+ }
+
+ if(extensionMimetype == null) continue; // If null is passed, it matches all files
+ extensionsMimeType.add(extensionMimetype);
+ count++;
+ }
+ mimeType = "*/*";
+ mimeTypes = extensionsMimeType.toArray(new String[0]);
}
@NonNull
@@ -33,6 +68,7 @@ public Intent createIntent(@NonNull Context context, @NonNull Object input) {
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType(mimeType);
+ if(mimeTypes != null) intent.putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes);
return intent;
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java
new file mode 100644
index 0000000000..fba82d388b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/LayoutBitmaps.java
@@ -0,0 +1,144 @@
+package net.kdt.pojavlaunch.customcontrols;
+
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import androidx.annotation.NonNull;
+
+import org.apache.commons.io.IOUtils;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipException;
+import java.util.zip.ZipInputStream;
+import java.util.zip.ZipOutputStream;
+
+public class LayoutBitmaps {
+ private static final Random mKeyPicker = new Random(System.nanoTime());
+ private final Map mBitmaps;
+
+ private LayoutBitmaps() {
+ mBitmaps = new HashMap<>();
+ }
+
+ private String pickKey() {
+ String key;
+ do {
+ key = Integer.toString(mKeyPicker.nextInt());
+ } while (mBitmaps.containsKey(key));
+ return key;
+ }
+
+ public Bitmap getBitmap(String key) {
+ return mBitmaps.get(key);
+ }
+
+ public String putBitmap(Bitmap bitmap, String oldKey) {
+ String newKey = pickKey();
+ mBitmaps.remove(oldKey);
+ if(bitmap != null) mBitmaps.put(newKey, bitmap);
+ return newKey;
+ }
+
+ public static LayoutBitmaps createEmpty() {
+ return new LayoutBitmaps();
+ }
+
+ private static ControlsContainer createEmpty(String controlsJson) {
+ return new ControlsContainer(controlsJson, new LayoutBitmaps());
+ }
+
+ private static ControlsContainer loadFromZip(ZipInputStream zipIn) throws IOException {
+ LayoutBitmaps layoutBitmaps = new LayoutBitmaps();
+ String layoutContent = null;
+ for(ZipEntry entry = zipIn.getNextEntry(); entry != null; entry = zipIn.getNextEntry()) {
+ if(entry.isDirectory()) continue;
+ String entryName = entry.getName();
+ if(entryName.equals("layout.json")) {
+ layoutContent = IOUtils.toString(zipIn, StandardCharsets.UTF_8);
+ continue;
+ }
+ layoutBitmaps.mBitmaps.put(entryName, BitmapFactory.decodeStream(zipIn));
+ zipIn.closeEntry();
+ }
+ if(layoutContent == null) throw new ZipException("Incorrect ZIP file structure");
+ return new ControlsContainer(layoutContent, layoutBitmaps);
+ }
+
+ private static ControlsContainer load(FileInputStream fileInputStream, long fileSize) throws IOException{
+ try(BufferedInputStream bufferedIn = new BufferedInputStream(fileInputStream)) {
+ boolean isZip;
+ bufferedIn.mark(4096);
+ try {
+ ZipInputStream zipIn = new ZipInputStream(bufferedIn);
+ isZip = zipIn.getNextEntry() != null;
+ } catch (ZipException e) {
+ isZip = false;
+ } catch (IOException e) {
+ throw e;
+ } catch (Exception e) {
+ isZip = false;
+ }
+ bufferedIn.reset();
+ if(isZip) {
+ try(ZipInputStream zipIn = new ZipInputStream(bufferedIn)) {
+ return loadFromZip(zipIn);
+ }
+ } else {
+ long meg = 1024L * 1024L;
+ if(fileSize > (25L * meg)) throw new IOException("Raw JSON control data size too large");
+ return createEmpty(IOUtils.toString(bufferedIn, StandardCharsets.UTF_8));
+ }
+ }
+ }
+
+ private static void storeZip(FileOutputStream fileOutputStream, ControlsContainer controlsContainer) throws IOException {
+ LayoutBitmaps bitmaps = controlsContainer.mLayoutZip;
+ try(ZipOutputStream zipOutputStream = new ZipOutputStream(fileOutputStream)) {
+ zipOutputStream.putNextEntry(new ZipEntry("layout.json"));
+ IOUtils.write(controlsContainer.mControlsJson, zipOutputStream, StandardCharsets.UTF_8);
+ zipOutputStream.closeEntry();
+ for(Map.Entry bitmapEntry : bitmaps.mBitmaps.entrySet()) {
+ Bitmap outBitmap = bitmapEntry.getValue();
+ if(outBitmap == null) continue;
+ zipOutputStream.putNextEntry(new ZipEntry(bitmapEntry.getKey()));
+ outBitmap.compress(Bitmap.CompressFormat.WEBP, 100, zipOutputStream);
+ zipOutputStream.closeEntry();
+ }
+ }
+ }
+
+ public static void store(FileOutputStream fileOutputStream, ControlsContainer controlsContainer) throws IOException {
+ LayoutBitmaps bitmaps = controlsContainer.mLayoutZip;
+ String controlsContent = controlsContainer.mControlsJson;
+ if(bitmaps.mBitmaps.isEmpty()) {
+ IOUtils.write(controlsContent, fileOutputStream, StandardCharsets.UTF_8);
+ return;
+ }
+ storeZip(fileOutputStream, controlsContainer);
+ }
+
+ public static @NonNull ControlsContainer load(File jsonLocation) throws IOException {
+ try (FileInputStream fileInputStream = new FileInputStream(jsonLocation)) {
+ return load(fileInputStream, jsonLocation.length());
+ }
+ }
+
+ public static final class ControlsContainer {
+ public final String mControlsJson;
+ public final LayoutBitmaps mLayoutZip;
+
+ public ControlsContainer(String mControlsJson, LayoutBitmaps mLayoutZip) {
+ this.mControlsJson = mControlsJson;
+ this.mLayoutZip = mLayoutZip;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java
new file mode 100644
index 0000000000..486dde6fe5
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/BackgroundTint.java
@@ -0,0 +1,43 @@
+package net.kdt.pojavlaunch.customcontrols.buttons;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.content.res.Resources;
+import android.graphics.Color;
+import android.util.TypedValue;
+
+import androidx.core.graphics.ColorUtils;
+
+public class BackgroundTint {
+ public static final int BACKGROUND_DEFAULT_TINT_ALPHA = 60;
+ public static final int BACKGROUND_TOGGLE_TINT_ALPHA = 128;
+
+ private static int lastTheme = System.identityHashCode(BackgroundTint.class);
+
+ private static final int[][] sState = new int[][] {
+ new int[] {android.R.attr.state_activated}
+ };
+ private static final int[] sDefaultTint = new int[] {
+ ColorUtils.setAlphaComponent(Color.WHITE, BACKGROUND_DEFAULT_TINT_ALPHA)
+ };
+ private static final int[] sToggleableTint = new int[] {
+ ColorUtils.setAlphaComponent(Color.WHITE, BACKGROUND_TOGGLE_TINT_ALPHA)
+ };
+
+ public static final ColorStateList DEFAULT_TINT_LIST = new ColorStateList(
+ sState, sDefaultTint
+ );
+ public static final ColorStateList TOGGLE_TINT_LIST = new ColorStateList(
+ sState, sToggleableTint
+ );
+
+ public static void applyToggleTint(Context context) {
+ Resources.Theme theme = context.getTheme();
+ int themeHash = theme.hashCode();
+ if(themeHash == lastTheme) return;
+ final TypedValue value = new TypedValue();
+ theme.resolveAttribute(android.R.attr.colorAccent, value, true);
+ sToggleableTint[0] = ColorUtils.setAlphaComponent(value.data, BACKGROUND_TOGGLE_TINT_ALPHA);
+ lastTheme = themeHash;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java
index 5fb9d687ba..efa07b234c 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java
@@ -15,6 +15,7 @@
import android.view.View;
import android.widget.TextView;
+import net.kdt.pojavlaunch.EfficientAndroidLWJGLKeycode;
import net.kdt.pojavlaunch.LwjglGlfwKeycode;
import net.kdt.pojavlaunch.MainActivity;
import net.kdt.pojavlaunch.R;
@@ -191,7 +192,7 @@ public void sendKeyPresses(boolean isDown){
setActivated(isDown);
for(int keycode : mProperties.keycodes){
if(keycode >= GLFW_KEY_UNKNOWN){
- sendKeyPress(keycode, CallbackBridge.getCurrentMods(), isDown);
+ sendKeyPress(keycode, EfficientAndroidLWJGLKeycode.getLwjglChar(keycode), CallbackBridge.getCurrentMods(), isDown);
CallbackBridge.setModifiers(keycode, isDown);
}else{
Log.i("punjabilauncher", "sendSpecialKey("+keycode+","+isDown+")");
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java
new file mode 100644
index 0000000000..d112644f24
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/DirectGamepad.java
@@ -0,0 +1,81 @@
+package net.kdt.pojavlaunch.customcontrols.gamepad;
+
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+
+import fr.spse.gamepad_remapper.GamepadHandler;
+import git.artdeell.dnbootstrap.glfw.GLFW;
+import git.artdeell.dnbootstrap.glfw.GamepadKeycodes;
+
+public class DirectGamepad implements GamepadHandler {
+ @Override
+ public void handleGamepadInput(int keycode, float value) {
+ int gKeycode = -1, gAxis = -1;
+ boolean normalize = false;
+ switch (keycode) {
+ case KeyEvent.KEYCODE_BUTTON_A: gKeycode = GamepadKeycodes.BUTTON_A; break;
+ case KeyEvent.KEYCODE_BUTTON_B: gKeycode = GamepadKeycodes.BUTTON_B; break;
+ case KeyEvent.KEYCODE_BUTTON_X: gKeycode = GamepadKeycodes.BUTTON_X; break;
+ case KeyEvent.KEYCODE_BUTTON_Y: gKeycode = GamepadKeycodes.BUTTON_Y; break;
+ case KeyEvent.KEYCODE_BUTTON_L1: gKeycode = GamepadKeycodes.BUTTON_LEFT_BUMPER; break;
+ case KeyEvent.KEYCODE_BUTTON_R1: gKeycode = GamepadKeycodes.BUTTON_RIGHT_BUMPER; break;
+ case KeyEvent.KEYCODE_BUTTON_L2:
+ case MotionEvent.AXIS_LTRIGGER:
+ gAxis = GamepadKeycodes.AXIS_LEFT_TRIGGER;
+ normalize = true;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_R2:
+ case MotionEvent.AXIS_RTRIGGER:
+ gAxis = GamepadKeycodes.AXIS_RIGHT_TRIGGER;
+ normalize = true;
+ break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBL: gKeycode = GamepadKeycodes.BUTTON_LEFT_THUMB; break;
+ case KeyEvent.KEYCODE_BUTTON_THUMBR: gKeycode = GamepadKeycodes.BUTTON_RIGHT_THUMB; break;
+ case KeyEvent.KEYCODE_BUTTON_START: gKeycode = GamepadKeycodes.BUTTON_START; break;
+ case KeyEvent.KEYCODE_BUTTON_SELECT: gKeycode = GamepadKeycodes.BUTTON_BACK; break;
+ case KeyEvent.KEYCODE_DPAD_UP: gKeycode = GamepadKeycodes.BUTTON_DPAD_UP; break;
+ case KeyEvent.KEYCODE_DPAD_DOWN: gKeycode = GamepadKeycodes.BUTTON_DPAD_DOWN; break;
+ case KeyEvent.KEYCODE_DPAD_LEFT: gKeycode = GamepadKeycodes.BUTTON_DPAD_LEFT; break;
+ case KeyEvent.KEYCODE_DPAD_RIGHT: gKeycode = GamepadKeycodes.BUTTON_DPAD_RIGHT; break;
+ case KeyEvent.KEYCODE_DPAD_CENTER:
+ // Behave the same way as the Gamepad here, as GLFW doesn't have a keycode
+ // for the dpad center.
+ GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_UP, GamepadKeycodes.GLFW_RELEASE);
+ GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_DOWN, GamepadKeycodes.GLFW_RELEASE);
+ GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_LEFT, GamepadKeycodes.GLFW_RELEASE);
+ GLFW.gamepadButtonBuffer.put(GamepadKeycodes.BUTTON_DPAD_RIGHT, GamepadKeycodes.GLFW_RELEASE);
+ return;
+ case MotionEvent.AXIS_X: gAxis = GamepadKeycodes.AXIS_LEFT_X; break;
+ case MotionEvent.AXIS_Y: gAxis = GamepadKeycodes.AXIS_LEFT_Y; break;
+ case MotionEvent.AXIS_Z: gAxis = GamepadKeycodes.AXIS_RIGHT_X; break;
+ case MotionEvent.AXIS_RZ: gAxis = GamepadKeycodes.AXIS_RIGHT_Y; break;
+ case MotionEvent.AXIS_HAT_X:
+ GLFW.gamepadButtonBuffer.put(
+ GamepadKeycodes.BUTTON_DPAD_LEFT,
+ value < -0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE
+ );
+ GLFW.gamepadButtonBuffer.put(
+ GamepadKeycodes.BUTTON_DPAD_RIGHT,
+ value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE
+ );
+ return;
+ case MotionEvent.AXIS_HAT_Y:
+ GLFW.gamepadButtonBuffer.put(
+ GamepadKeycodes.BUTTON_DPAD_UP,
+ value < -0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE
+ );
+ GLFW.gamepadButtonBuffer.put(
+ GamepadKeycodes.BUTTON_DPAD_DOWN,
+ value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE
+ );
+ return;
+ }
+ if(gKeycode != -1) {
+ GLFW.gamepadButtonBuffer.put(gKeycode, value > 0.85 ? GamepadKeycodes.GLFW_PRESS : GamepadKeycodes.GLFW_RELEASE);
+ }
+ if(gAxis != -1) {
+ if(normalize) value = value * 2 - 1;
+ GLFW.gamepadAxisBuffer.put(gAxis, value);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java
index fe97ec601f..e810273e7e 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java
@@ -90,6 +90,7 @@ public class Gamepad implements GrabListener, GamepadHandler {
private boolean mRemoved = false;
public Gamepad(View contextView, InputDevice inputDevice, GamepadDataProvider mapProvider, boolean showCursor){
+
Settings.setDeadzoneScale(PREF_DEADZONE_SCALE);
mScreenChoreographer = Choreographer.getInstance();
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java
index 38f6dd4ca7..9bee64f2d6 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/AndroidPointerCapture.java
@@ -1,21 +1,29 @@
package net.kdt.pojavlaunch.customcontrols.mouse;
+import static net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF;
+import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE;
+
+import android.content.SharedPreferences;
import android.os.Build;
import android.view.InputDevice;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewTreeObserver;
+import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
+import net.kdt.pojavlaunch.GrabListener;
import net.kdt.pojavlaunch.MinecraftGLSurface;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import org.lwjgl.glfw.CallbackBridge;
+import java.util.function.Consumer;
+
@RequiresApi(api = Build.VERSION_CODES.O)
-public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChangeListener, View.OnCapturedPointerListener {
+public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChangeListener, View.OnCapturedPointerListener, GrabListener, SharedPreferences.OnSharedPreferenceChangeListener {
private static final float TOUCHPAD_SCROLL_THRESHOLD = 1;
private final AbstractTouchpad mTouchpad;
private final View mHostView;
@@ -32,14 +40,43 @@ public AndroidPointerCapture(AbstractTouchpad touchpad, View hostView) {
this.mHostView = hostView;
hostView.setOnCapturedPointerListener(this);
hostView.getViewTreeObserver().addOnWindowFocusChangeListener(this);
+ DEFAULT_PREF.registerOnSharedPreferenceChangeListener(this);
+ CallbackBridge.addGrabListener(this);
}
+ /**
+ * Checks whether or not the touchpad is already enabled and if user prefers virtual cursor
+ * if they don't, the touchpad is not enabled
+ */
private void enableTouchpadIfNecessary() {
- if(!mTouchpad.getDisplayState()) mTouchpad.enable(true);
+ if(!mTouchpad.getDisplayState() && PREF_MOUSE_GRAB_FORCE) mTouchpad.enable(true);
+ }
+
+ // Needed so it releases the cursor when inside game menu
+ @Override
+ public void onGrabState(boolean isGrabbing) {
+ handleAutomaticCapture();
+ }
+ // It's only here so the side-dialog changes it live
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, @Nullable String key) {
+ if (sharedPreferences.getBoolean("always_grab_mouse", true)){
+ enableTouchpadIfNecessary();
+ } else mTouchpad.disable();
+ handleAutomaticCapture();
}
public void handleAutomaticCapture() {
- if(!mHostView.hasWindowFocus()) {
+ // isGrabbing checks for whether we are in menu
+ if (!CallbackBridge.isGrabbing()
+ && !PREF_MOUSE_GRAB_FORCE) {
+ mHostView.releasePointerCapture();
+ return;
+ }
+ if (mHostView.hasPointerCapture()) {
+ enableTouchpadIfNecessary();
+ }
+ if (!mHostView.hasWindowFocus()) {
mHostView.requestFocus();
} else {
mHostView.requestPointerCapture();
@@ -49,28 +86,57 @@ public void handleAutomaticCapture() {
@Override
public boolean onCapturedPointer(View view, MotionEvent event) {
checkSameDevice(event.getDevice());
+ int axisX, axisY;
+ // Sources can claim to be a relative device by belonging to the trackball class, if so then
+ // we could just use the relative axis directly but some OEMs report absolute touchpads as
+ // trackballs, verify that it gives us relative input with mDeviceSupportsRelativeAxis and
+ // hope whatever relative value it spits out is valid, otherwise, revert to non-relative and
+ // hope the OS does magic
+ if (mDeviceSupportsRelativeAxis) {
+ axisX = MotionEvent.AXIS_RELATIVE_X;
+ axisY = MotionEvent.AXIS_RELATIVE_Y;
+ } else {
+ axisX = MotionEvent.AXIS_X;
+ axisY = MotionEvent.AXIS_Y;
+ }
+
// Yes, we actually not only receive relative mouse events here, but also absolute touchpad ones!
// Therefore, we need to know when it's a touchpad and when it's a mouse.
+ if ((event.getSource() & InputDevice.SOURCE_CLASS_TRACKBALL) != 0){
+ processBatchEvents(event, axisX, axisY, this::processMousePos);
+ mVector[0] = event.getAxisValue(axisX);
+ mVector[1] = event.getAxisValue(axisY);
+ return processAndSendMotionEvent(event);
+ }
+ // If it's not a trackball, it's likely a touchpad and needs tracking like a touchscreen.
+ processBatchEvents(event, MotionEvent.AXIS_X, MotionEvent.AXIS_Y, mPointerTracker::trackEvent);
+ // Touchscreens should never be using relative axis...right? Well it's an easy fix if there's
+ // a bug report!
+ mPointerTracker.trackEvent(event); // This also updates the mVector variable.
+ return processAndSendMotionEvent(event);
+ }
- if((event.getSource() & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) {
- // If the source claims to be a relative device by belonging to the trackball class,
- // use its coordinates directly.
- if(mDeviceSupportsRelativeAxis) {
- // If some OEM decides to do a funny and make an absolute touchpad report itself as
- // a trackball, we will at least have semi-valid relative positions
- mVector[0] = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
- mVector[1] = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
- }else {
- // Otherwise trust the OS, i guess??
- mVector[0] = event.getX();
- mVector[1] = event.getY();
- }
- }else {
- // If it's not a trackball, it's likely a touchpad and needs tracking like a touchscreen.
- mPointerTracker.trackEvent(event);
- // The relative position will already be written down into the mVector variable.
+ /**
+ * Android handles high refresh rate mice by batching their movements in between screen refresh.
+ * This basically locks your input hz to what your screen hz is.
+ * Screw that, handle it as the high hz input that it is, to the extent permitted by the rules.
+ * Processes historical batched events one by one, sets mVector, and runs {@code moveCursorWithEvent}.
+ * @param event The MotionEvent
+ * @param axisX The axis identifier for the axis value to retrieve
+ * @param axisY The axis identifier for the axis value to retrieve
+ * @param moveCursorWithEvent Lambda that triggers cursor movement. Runs after mVector update.
+ */
+ private void processBatchEvents(MotionEvent event, int axisX, int axisY, Consumer moveCursorWithEvent){
+ // Process batched events first as said in Android docs https://developer.android.com/reference/android/view/MotionEvent#batching
+ for (int h = 0; h < event.getHistorySize(); h++){
+ mVector[0] = event.getHistoricalAxisValue(axisX, h);
+ mVector[1] = event.getHistoricalAxisValue(axisY, h);
+ // All historical values are ACTION_MOVE. This is the lamest part. Damn your rules.
+ moveCursorWithEvent.accept(event);
}
+ }
+ private void processMousePos(MotionEvent event) {
if(!CallbackBridge.isGrabbing()) {
enableTouchpadIfNecessary();
// Yes, if the user's touchpad is multi-touch we will also receive events for that.
@@ -89,6 +155,10 @@ public boolean onCapturedPointer(View view, MotionEvent event) {
CallbackBridge.mouseY += (mVector[1] * LauncherPreferences.PREF_SCALE_FACTOR);
CallbackBridge.sendCursorPos(CallbackBridge.mouseX, CallbackBridge.mouseY);
}
+ }
+
+ private boolean processAndSendMotionEvent(MotionEvent event) {
+ processMousePos(event);
switch (event.getActionMasked()) {
case MotionEvent.ACTION_MOVE:
@@ -128,7 +198,11 @@ private void reinitializeDeviceSpecificProperties(InputDevice inputDevice) {
@Override
public void onWindowFocusChanged(boolean hasFocus) {
- if(hasFocus && Tools.isAndroid8OrHigher()) mHostView.requestPointerCapture();
+ if (!CallbackBridge.isGrabbing() // Only capture if not in menu and user said so
+ && !PREF_MOUSE_GRAB_FORCE) {
+ return;
+ }
+ if (hasFocus && Tools.isAndroid8OrHigher()) mHostView.requestPointerCapture();
}
public void detach() {
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java
new file mode 100644
index 0000000000..f33f047960
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/CursorContainer.java
@@ -0,0 +1,39 @@
+package net.kdt.pojavlaunch.customcontrols.mouse;
+
+import android.graphics.Canvas;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+
+/**
+ * Contains cursor data and the draw method
+ */
+public class CursorContainer {
+ private final Drawable drawable;
+ private final int xHotspot;
+ private final int yHotspot;
+
+ public CursorContainer(Drawable drawable, int xHotspot, int yHotspot) {
+ this.drawable = drawable;
+ this.xHotspot = xHotspot;
+ this.yHotspot = yHotspot;
+ }
+
+ public void draw(@NonNull Canvas canvas) {
+ canvas.translate(-xHotspot, -yHotspot);
+
+ drawable.draw(canvas);
+ }
+
+ public Drawable getDrawable() {
+ return drawable;
+ }
+
+ public int getXHotspot() {
+ return xHotspot;
+ }
+
+ public int getYHotspot() {
+ return yHotspot;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java
new file mode 100644
index 0000000000..0328718e18
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/DistanceGesture.java
@@ -0,0 +1,35 @@
+package net.kdt.pojavlaunch.customcontrols.mouse;
+
+import android.os.Handler;
+
+import net.kdt.pojavlaunch.utils.MathUtils;
+
+public abstract class DistanceGesture extends ValidatorGesture {
+
+ protected float mGestureTravelX, mGestureTravelY;
+
+ public DistanceGesture(Handler mHandler) {
+ super(mHandler);
+ }
+
+ public void inputEvent() {
+ if(!shouldSubmitGesture()) return;
+ if(submit()) {
+ mGestureTravelX = 0;
+ mGestureTravelY = 0;
+ onGestureSubmitted();
+ }
+ }
+
+ public void setMotion(float deltaX, float deltaY) {
+ mGestureTravelX += deltaX;
+ mGestureTravelY += deltaY;
+ }
+
+ protected boolean travelBelowThreshold(float th) {
+ return MathUtils.dist(mGestureTravelX, mGestureTravelY) <= th;
+ }
+
+ abstract void onGestureSubmitted();
+ abstract boolean shouldSubmitGesture();
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java
index 7a1f71d861..78ca9e7109 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java
@@ -132,7 +132,7 @@ public void applyMotionVector(float x, float y) {
public void enable(boolean supposed) {
if(mDisplayState) return;
mDisplayState = true;
- if(supposed && CallbackBridge.isGrabbing()) return;
+ if(supposed && CallbackBridge.isGrabbing() && LauncherPreferences.PREF_MOUSE_GRAB_FORCE) return;
_enable();
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java
new file mode 100644
index 0000000000..24f229ebf7
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/AcquireableTaskMetadata.java
@@ -0,0 +1,15 @@
+package net.kdt.pojavlaunch.downloader;
+
+import java.io.IOException;
+
+public abstract class AcquireableTaskMetadata extends TaskMetadata {
+ public AcquireableTaskMetadata(int mirrorType) {
+ super(null, null, mirrorType);
+ }
+
+ /**
+ * Fill the missing fields of this AcquireableTaskMetadata (by, for example, performing an API request)
+ * @throws IOException if metadata acquisition failed
+ */
+ public abstract void acquireMetadata() throws IOException;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java
new file mode 100644
index 0000000000..0ee9f431f4
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/BytesCopiedListener.java
@@ -0,0 +1,5 @@
+package net.kdt.pojavlaunch.downloader;
+
+public interface BytesCopiedListener {
+ void onBytesCopied(int nbytes);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java
new file mode 100644
index 0000000000..53dbec7b76
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CheckFileOnDiskTask.java
@@ -0,0 +1,44 @@
+package net.kdt.pojavlaunch.downloader;
+
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.HashUtils;
+
+import java.io.File;
+import java.io.IOException;
+
+public class CheckFileOnDiskTask extends DownloaderTask {
+ private final boolean mAfterDownload;
+ CheckFileOnDiskTask(TaskMetadata mMetadata, Downloader mHostDownloader) {
+ super(mMetadata, mHostDownloader);
+ this.mAfterDownload = false;
+ }
+
+ CheckFileOnDiskTask(TaskMetadata mMetadata, Downloader mHostDownloader, boolean mAfterDownload) {
+ super(mMetadata, mHostDownloader);
+ this.mAfterDownload = mAfterDownload;
+ }
+
+ @Override
+ protected void performTask() throws IOException {
+ boolean checkResult = checkFile();
+ if(checkResult) {
+ if(!mAfterDownload) mDownloader.addSize(mMetadata.size);
+ mDownloader.fileComplete();
+ }else {
+ if(!mAfterDownload) mDownloader.submitFileForDownload(mMetadata);
+ else throw new IOException("Failed to verify "+mMetadata.toString());
+ }
+ }
+
+ private boolean checkFile() throws IOException {
+ File localFile = mMetadata.path;
+ if(!localFile.exists()) return false;
+ if(!LauncherPreferences.PREF_VERIFY_FILES) return true;
+ if(mMetadata.size != -1) {
+ if(mMetadata.size != localFile.length()) return false;
+ if(LauncherPreferences.PREF_RAPID_START && !mAfterDownload) return true;
+ }
+ return mMetadata.sha1Hash == null || HashUtils.compareSHA1(localFile, mMetadata.sha1Hash);
+ }
+
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java
new file mode 100644
index 0000000000..1602a1c633
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/CompleteMetadataTask.java
@@ -0,0 +1,61 @@
+package net.kdt.pojavlaunch.downloader;
+
+import android.util.Log;
+
+import net.kdt.pojavlaunch.mirrors.DownloadMirror;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+
+import java.io.IOException;
+import java.net.URL;
+
+public class CompleteMetadataTask extends DownloaderTask {
+ CompleteMetadataTask(TaskMetadata mMetadata, Downloader mHostDownloader) {
+ super(mMetadata, mHostDownloader);
+ }
+
+ @Override
+ protected void performTask() throws IOException {
+ if(mMetadata instanceof AcquireableTaskMetadata) {
+ ((AcquireableTaskMetadata)mMetadata).acquireMetadata();
+ if(mMetadata.url == null) throw new IOException("Metadata acquisition did not supply the URL!");
+ }
+ if(mMetadata.url != null) {
+ getFileSize();
+ getLibrarySha1Hash();
+ }
+ if(mMetadata.size == -1) {
+ mDownloader.disableSizeCounter();
+ }
+ mDownloader.fileComplete();
+ }
+
+ private void getLibrarySha1Hash() {
+ if(mMetadata.sha1Hash != null) return;
+ if(mMetadata.mirrorType != DownloadMirror.DOWNLOAD_CLASS_LIBRARIES) return;
+
+ if(!LauncherPreferences.PREF_VERIFY_FILES) return;
+
+ // No need to try and obtain the hash if the file is qualified for rapid start check skip
+ if(LauncherPreferences.PREF_RAPID_START && mMetadata.size != -1 && mMetadata.path.length() == mMetadata.size) return;
+
+ try {
+ mMetadata.sha1Hash = mDownloader.downloadString(new URL(mMetadata.url + ".sha1"));
+ }catch (IOException e) {
+ Log.i("CompleteMetadataTask", "Failed to get server hash for "+mMetadata.path.getName(), e);
+ }
+ }
+
+ private void getFileSize() {
+ if(mMetadata.size != -1) return;
+ try {
+ mMetadata.size = mDownloader.getFileContentLength(mMetadata.url);
+ Log.i("CompleteMetadataTask", "Got size: " + mMetadata.size +" for " + mMetadata.path.getName());
+ }catch (IOException e) {
+ Log.i("CompleteMetadataTask", "Failed to get size for " + mMetadata.path.getName(), e);
+ }
+ }
+
+ protected static boolean shouldCompleteMetadata(TaskMetadata metadata) {
+ return metadata instanceof AcquireableTaskMetadata || (metadata.sha1Hash == null && metadata.mirrorType == DownloadMirror.DOWNLOAD_CLASS_LIBRARIES) || metadata.size == -1;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java
new file mode 100644
index 0000000000..5a0450b0aa
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloadFileTask.java
@@ -0,0 +1,46 @@
+package net.kdt.pojavlaunch.downloader;
+
+import java.io.IOException;
+import java.util.concurrent.atomic.AtomicLong;
+
+public class DownloadFileTask extends DownloaderTask implements BytesCopiedListener {
+ private final AtomicLong mBytesDownloaded = new AtomicLong();
+ DownloadFileTask(TaskMetadata mMetadata, Downloader mHostDownloader) {
+ super(mMetadata, mHostDownloader);
+ }
+
+ @Override
+ protected void performTask() throws IOException {
+ tryDownload(0, true);
+ mDownloader.submitFileForRecheck(mMetadata);
+ }
+
+ private void performRetry(int attempt, boolean rangeAllowed) throws IOException{
+ mDownloader.addSize(-mBytesDownloaded.get()); // It will get readded again on next tryDownload() if range is allowed
+ tryDownload(attempt + 1, rangeAllowed);
+ }
+
+ private void tryDownload(int attempt, boolean rangeAllowed) throws IOException {
+ try {
+ if(!mMetadata.path.exists() || !rangeAllowed) {
+ mBytesDownloaded.set(0);
+ mDownloader.downloadFile(mMetadata.path, mMetadata.url, this);
+ } else {
+ long alreadyDownloaded = mMetadata.path.length();
+ mBytesDownloaded.set(alreadyDownloaded);
+ mDownloader.addSize(alreadyDownloaded);
+ rangeAllowed = mDownloader.tryContinueDownload(mMetadata.path, mMetadata.size, mMetadata.url, this);
+ if(!rangeAllowed) performRetry(attempt, false);
+ }
+ }catch (IOException e) {
+ if(attempt == 5) throw e;
+ performRetry(attempt, rangeAllowed);
+ }
+ }
+
+ @Override
+ public void onBytesCopied(int nbytes) {
+ mBytesDownloaded.getAndAdd(nbytes);
+ mDownloader.addSize(nbytes);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java
new file mode 100644
index 0000000000..3b4af80f03
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/Downloader.java
@@ -0,0 +1,238 @@
+package net.kdt.pojavlaunch.downloader;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.tasks.SpeedCalculator;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.concurrent.atomic.AtomicReference;
+
+import git.artdeell.mojo.R;
+
+public class Downloader {
+ private static final double ONE_MEGABYTE = (1024d * 1024d);
+ private static final ThreadLocal sThreadLocalBuffer = new ThreadLocal<>();
+ private final String mProgressKey;
+ private final AtomicReference mThreadException = new AtomicReference<>();
+ private final AtomicInteger mDownloadedFileCounter = new AtomicInteger();
+ private final AtomicLong mDownloadedSizeCounter = new AtomicLong();
+ private final AtomicLong mInternetUsageCounter = new AtomicLong();
+ private final AtomicBoolean mUseSizeProgress = new AtomicBoolean(true);
+ private final SpeedCalculator mSpeedCalculator = new SpeedCalculator();
+ private ExecutorService mDownloadService;
+ private ExecutorService mVerifyService;
+
+ public Downloader(String mProgressKey) {
+ this.mProgressKey = mProgressKey;
+ }
+
+ protected void runDownloads(ArrayList extends TaskMetadata> downloads) throws IOException, InterruptedException {
+ insertMetadata(downloads);
+ performDownloads(downloads);
+ }
+
+ private void performDownloads(ArrayList extends TaskMetadata> metadata) throws IOException, InterruptedException {
+ mThreadException.set(null);
+ mDownloadedFileCounter.set(0);
+ mDownloadedSizeCounter.set(0);
+ mDownloadService = Executors.newFixedThreadPool(3);
+ int verifyThreads = Math.max(2, Runtime.getRuntime().availableProcessors() - 2);
+ mVerifyService = Executors.newFixedThreadPool(verifyThreads, r -> {
+ Thread thread = new Thread(r);
+ thread.setPriority(10);
+ thread.setName("verify thread");
+ return thread;
+ });
+ long totalSize = 0;
+ int totalCount = metadata.size();
+ boolean sizeCounter = mUseSizeProgress.get();
+ for(TaskMetadata element : metadata) {
+ totalSize += element.size;
+ mVerifyService.submit(new CheckFileOnDiskTask(element, this));
+ }
+ double totalMegabytes = totalSize / ONE_MEGABYTE;
+ while(mDownloadedFileCounter.get() < totalCount) {
+ IOException exception = mThreadException.get();
+ if(exception != null) throw exception;
+ if(sizeCounter) reportSizeProgress(totalMegabytes);
+ else reportCountProgress(R.string.newerdl_downloading_files_count, totalCount);
+ Thread.sleep(33);
+ }
+ mDownloadService.shutdown();
+ mVerifyService.shutdown();
+ if(!mDownloadService.awaitTermination(100, TimeUnit.MILLISECONDS) ||
+ !mVerifyService.awaitTermination(100, TimeUnit.MILLISECONDS)) {
+ throw new RuntimeException("BUG! The file counter is wrong. Maybe. Send this to artDev.");
+ }
+ }
+
+ private void insertMetadata(ArrayList extends TaskMetadata> metadata) throws IOException, InterruptedException {
+ mThreadException.set(null);
+ mDownloadedFileCounter.set(0);
+ ArrayList reducedList = new ArrayList<>();
+ for(TaskMetadata element : metadata) {
+ if(!CompleteMetadataTask.shouldCompleteMetadata(element)) continue;
+ reducedList.add(element);
+ }
+ if(reducedList.isEmpty()) return;
+ try (ExecutorService executorService = Executors.newFixedThreadPool(4)) {
+ for(TaskMetadata element : reducedList) executorService.submit(new CompleteMetadataTask(element, this));
+ executorService.shutdown();
+ while (!executorService.awaitTermination(33, TimeUnit.MILLISECONDS)) {
+ IOException exception = mThreadException.get();
+ if(exception != null) throw exception;
+ reportCountProgress(R.string.newerdl_inserting_metadata_count, reducedList.size());
+ }
+ }
+ }
+
+ private double getSpeed() {
+ return mSpeedCalculator.feed(mInternetUsageCounter.get()) / ONE_MEGABYTE;
+ }
+
+ private void reportCountProgress(int resource, int total) {
+ int downloadedCount = mDownloadedFileCounter.get();
+ int progress = (int) ((downloadedCount / (float)total) * 100f);
+ ProgressLayout.setProgress(mProgressKey, progress, resource,
+ downloadedCount, total, getSpeed()
+ );
+ }
+
+ private void reportSizeProgress(double totalMegabytes) {
+ double downloadedMegabytes = mDownloadedSizeCounter.get() / ONE_MEGABYTE;
+ int progress = (int) (downloadedMegabytes / totalMegabytes * 100d);
+ ProgressLayout.setProgress(mProgressKey, progress, R.string.newerdl_downloading_files_size,
+ downloadedMegabytes, totalMegabytes, getSpeed()
+ );
+ }
+
+ protected void taskException(IOException e) {
+ mThreadException.set(e);
+ }
+
+ protected void disableSizeCounter() {
+ mUseSizeProgress.lazySet(false);
+ }
+
+ protected void submitFileForDownload(TaskMetadata taskMetadata) {
+ mDownloadService.submit(new DownloadFileTask(taskMetadata, this));
+ }
+
+ protected void submitFileForRecheck(TaskMetadata taskMetadata) {
+ mVerifyService.submit(new CheckFileOnDiskTask(taskMetadata, this, true));
+ }
+
+ protected void fileComplete() {
+ mDownloadedFileCounter.getAndIncrement();
+ }
+
+ protected void addSize(long bytes) {
+ mDownloadedSizeCounter.getAndAdd(bytes);
+ }
+
+ private void copy(InputStream inputStream, OutputStream outputStream, BytesCopiedListener listener) throws IOException {
+ byte[] buffer = getBuffer();
+ int readLen;
+ while((readLen = inputStream.read(buffer)) != -1) {
+ outputStream.write(buffer, 0, readLen);
+ if(listener != null) listener.onBytesCopied(readLen);
+ mInternetUsageCounter.getAndAdd(readLen);
+ }
+ }
+
+ private static HttpURLConnection openConnection(URL url) throws IOException {
+ HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+ connection.setReadTimeout(10000);
+ connection.setRequestProperty("User-Agent", DownloadUtils.USER_AGENT);
+ connection.setDoInput(true);
+ connection.setDoOutput(false);
+ return connection;
+ }
+
+ protected void downloadToStream(HttpURLConnection connection, OutputStream outputStream, BytesCopiedListener listener) throws IOException {
+ InputStream inputStream = connection.getInputStream();
+ copy(inputStream, outputStream, listener);
+ }
+
+ protected String downloadString(URL url) throws IOException {
+ HttpURLConnection connection = openConnection(url);
+ int length = connection.getContentLength();
+ if(length < 0) length = 32;
+ try(ByteArrayOutputStream outputStream = new ByteArrayOutputStream(length)) {
+ downloadToStream(connection, outputStream, null);
+ return new String(outputStream.toByteArray(), StandardCharsets.UTF_8);
+ }finally {
+ connection.disconnect();
+ }
+ }
+
+ protected void downloadFile(File file, URL url, BytesCopiedListener listener) throws IOException {
+ HttpURLConnection connection = openConnection(url);
+ try(FileOutputStream outputStream = new FileOutputStream(file)) {
+ downloadToStream(connection, outputStream, listener);
+ }finally {
+ connection.disconnect();
+ }
+ }
+
+ protected boolean tryContinueDownload(File file, long wantedLength, URL url, BytesCopiedListener listener) throws IOException {
+ HttpURLConnection connection = openConnection(url);
+ String range = String.format(Locale.ENGLISH,"bytes %d-%d/%d", file.length(), wantedLength-1, wantedLength);
+ connection.setRequestProperty("Content-Range", range);
+ try {
+ connection.connect();
+ int responseCode = connection.getResponseCode();
+ if(responseCode != 206) {
+ return false;
+ }
+ try(FileOutputStream outputStream = new FileOutputStream(file, true)) {
+ downloadToStream(connection, outputStream, listener);
+ return true;
+ }
+ }finally {
+ connection.disconnect();
+ }
+ }
+
+ protected long getFileContentLength(URL url) throws IOException {
+ HttpURLConnection connection = openConnection(url);
+
+ connection.setConnectTimeout(2000);
+ connection.setReadTimeout(2000);
+
+ connection.setRequestMethod("HEAD");
+ connection.connect();
+ int response = connection.getResponseCode();
+ if(response >= 400) {
+ return -1;
+ }else {
+ return connection.getContentLength();
+ }
+ }
+
+ public static byte[] getBuffer() {
+ byte[] buffer = sThreadLocalBuffer.get();
+ if(buffer == null) {
+ buffer = new byte[8192];
+ sThreadLocalBuffer.set(buffer);
+ }
+ return buffer;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java
new file mode 100644
index 0000000000..6f6b796c15
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/DownloaderTask.java
@@ -0,0 +1,24 @@
+package net.kdt.pojavlaunch.downloader;
+
+import java.io.IOException;
+
+public abstract class DownloaderTask implements Runnable {
+ protected final TaskMetadata mMetadata;
+ protected final Downloader mDownloader;
+
+ protected DownloaderTask(TaskMetadata mMetadata, Downloader mHostDownloader) {
+ this.mMetadata = mMetadata;
+ this.mDownloader = mHostDownloader;
+ }
+
+ @Override
+ public final void run() {
+ try {
+ performTask();
+ }catch (IOException e) {
+ mDownloader.taskException(e);
+ }
+ }
+
+ protected abstract void performTask() throws IOException;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java
new file mode 100644
index 0000000000..39e0aacdf1
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/downloader/TaskMetadata.java
@@ -0,0 +1,32 @@
+package net.kdt.pojavlaunch.downloader;
+
+import androidx.annotation.NonNull;
+
+import java.io.File;
+import java.net.URL;
+
+public class TaskMetadata {
+ public File path;
+ public URL url;
+ public final int mirrorType;
+ public long size;
+ public String sha1Hash;
+
+ public TaskMetadata(File path, URL url, int mirrorType) {
+ this.path = path;
+ this.url = url;
+ this.mirrorType = mirrorType;
+ }
+
+ public TaskMetadata(File path, URL url, long size, String hash, int mirrorType) {
+ this(path, url, mirrorType);
+ this.sha1Hash = hash;
+ this.size = size;
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ return "TaskMetadata{\nurl="+url+";\npath="+path+"\nhash="+sha1Hash+";\nsize="+size+"\n}";
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java
new file mode 100644
index 0000000000..68143392c5
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/DeleteConfirmDialogFragment.java
@@ -0,0 +1,43 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.app.Dialog;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AlertDialog;
+import androidx.fragment.app.DialogFragment;
+
+import git.artdeell.mojo.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.instances.Instance;
+import net.kdt.pojavlaunch.instances.Instances;
+import net.kdt.pojavlaunch.instances.InstanceIconProvider;
+import java.io.IOException;
+
+
+public class DeleteConfirmDialogFragment extends DialogFragment {
+ private final Instance mInstance = Instances.loadSelectedInstance();
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
+ if (mInstance == null) dismiss();
+ return new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.instance_delete)
+ .setMessage(R.string.instance_delete_confirmation)
+ .setPositiveButton(R.string.global_delete, (dialog, which) -> {
+ if (mInstance == null) return;
+ InstanceIconProvider.dropIcon(mInstance);
+ Tools.removeCurrentFragment(requireActivity());
+ try {
+ Instances.removeInstance(mInstance);
+ } catch (IOException e) {
+ Tools.showErrorRemote(e);
+ }
+ })
+ .setNegativeButton(R.string.global_no, null)
+ .create();
+ }
+ public static String TAG = "delete_dialog_confirm";
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java
new file mode 100644
index 0000000000..2146dc88f8
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ElyByLoginFragment.java
@@ -0,0 +1,16 @@
+package net.kdt.pojavlaunch.fragments;
+
+import net.kdt.pojavlaunch.extra.ExtraConstants;
+
+public class ElyByLoginFragment extends OAuthFragment {
+ public static final String TAG = "ELYBY_LOGIN_FRAGMENT";
+ public ElyByLoginFragment() {
+ super("internalredirect",
+ "https://account.ely.by/oauth2/v1" +
+ "?client_id=mojolauncher2" +
+ "&redirect_uri=internalredirect%3A%2F%2Fcomplete" +
+ "&response_type=code" +
+ "&scope=account_info%20offline_access%20minecraft_server_session",
+ ExtraConstants.ELYBY_LOGIN_TODO);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java
new file mode 100644
index 0000000000..6bc79dcceb
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ForgelikeInstallFragment.java
@@ -0,0 +1,61 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.view.LayoutInflater;
+import android.widget.ExpandableListAdapter;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.instances.InstanceInstaller;
+import net.kdt.pojavlaunch.instances.Instances;
+import net.kdt.pojavlaunch.modloaders.ForgelikeUtils;
+import net.kdt.pojavlaunch.modloaders.ForgelikeVersionListAdapter;
+import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+
+public abstract class ForgelikeInstallFragment extends ModVersionListFragment> {
+ private final ForgelikeUtils mUtils;
+ public ForgelikeInstallFragment(ForgelikeUtils utils, String mFragmentTag) {
+ super(mFragmentTag);
+ this.mUtils = utils;
+ }
+
+ @Override
+ public List loadVersionList() throws IOException {
+ return mUtils.downloadVersions();
+ }
+
+ @Override
+ public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) {
+ return ()->createInstance((String) selectedVersion, listenerProxy);
+ }
+
+ @Override
+ public ExpandableListAdapter createAdapter(List versionList, LayoutInflater layoutInflater) {
+ return new ForgelikeVersionListAdapter(versionList, layoutInflater, mUtils);
+ }
+
+ @Override
+ public void onDownloadFinished(Context context, File downloadedFile) {
+ }
+
+ private void createInstance(String selectedVersion, ModloaderListenerProxy listenerProxy) {
+ try {
+ ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0);
+ InstanceInstaller instanceInstaller = mUtils.createInstaller(selectedVersion);
+ Instances.createInstance(instance -> {
+ instance.name = mUtils.getName();
+ instance.icon = mUtils.getIconName();
+ instance.installer = instanceInstaller;
+ }, selectedVersion);
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ instanceInstaller.start();
+ listenerProxy.onDownloadFinished(null);
+ }catch (IOException e) {
+ listenerProxy.onDownloadError(e);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java
new file mode 100644
index 0000000000..8ce31ec537
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstanceEditorFragment.java
@@ -0,0 +1,246 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ArrayAdapter;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.EditText;
+import android.widget.ImageView;
+import android.widget.Spinner;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import git.artdeell.mojo.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.extra.ExtraConstants;
+import net.kdt.pojavlaunch.extra.ExtraCore;
+import net.kdt.pojavlaunch.instances.Instance;
+import net.kdt.pojavlaunch.instances.Instances;
+import net.kdt.pojavlaunch.multirt.MultiRTUtils;
+import net.kdt.pojavlaunch.multirt.RTSpinnerAdapter;
+import net.kdt.pojavlaunch.multirt.Runtime;
+import net.kdt.pojavlaunch.instances.InstanceIconProvider;
+import net.kdt.pojavlaunch.profiles.VersionSelectorDialog;
+import net.kdt.pojavlaunch.utils.CropperUtils;
+import net.kdt.pojavlaunch.utils.RendererCompatUtil;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class InstanceEditorFragment extends Fragment implements CropperUtils.CropperReceiver {
+ public static final String TAG = "InstanceEditorFragment";
+
+ private Instance mInstance;
+ private String mSelectedControlLayout;
+ private Button mSaveButton, mDeleteButton, mControlSelectButton, mVersionSelectButton;
+ private Spinner mDefaultRuntime, mDefaultRenderer;
+ private EditText mDefaultName, mDefaultJvmArgument;
+ private TextView mDefaultVersion, mDefaultControl;
+ private ImageView mInstanceIcon;
+ private CheckBox mSharedDataCheckbox;
+ private int mRecommendedIconSize;
+ private final ActivityResultLauncher> mCropperLauncher = CropperUtils.registerCropper(this, this);
+
+ private List mRenderNames;
+
+ public InstanceEditorFragment(){
+ super(R.layout.fragment_instance_editor);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
+ // Paths, which can be changed
+ String value = (String) ExtraCore.consumeValue(ExtraConstants.FILE_SELECTOR);
+ if(value != null){
+ mSelectedControlLayout = value;
+ }
+ return super.onCreateView(inflater, container, savedInstanceState);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ bindViews(view);
+
+ RendererCompatUtil.RenderersList renderersList = RendererCompatUtil.getCompatibleRenderers(view.getContext());
+ mRenderNames = renderersList.rendererIds;
+ List renderList = new ArrayList<>(renderersList.rendererDisplayNames.length + 1);
+ renderList.addAll(Arrays.asList(renderersList.rendererDisplayNames));
+ renderList.add(view.getContext().getString(R.string.global_default));
+ mDefaultRenderer.setAdapter(new ArrayAdapter<>(view.getContext(), R.layout.item_simple_list_1, renderList));
+
+ // Set up behaviors
+ mSaveButton.setOnClickListener(v -> {
+ InstanceIconProvider.dropIcon(mInstance);
+ save();
+ Tools.backToMainMenu(requireActivity());
+ });
+
+ mDeleteButton.setOnClickListener(v -> {
+ DeleteConfirmDialogFragment dialogFragment = new DeleteConfirmDialogFragment();
+ dialogFragment.show(getChildFragmentManager(), "delete_dialog_confirm");
+ });
+
+ View.OnClickListener controlSelectListener = getControlSelectListener();
+ mControlSelectButton.setOnClickListener(controlSelectListener);
+ mDefaultControl.setOnClickListener(controlSelectListener);
+
+ // Setup the expendable list behavior
+ View.OnClickListener versionSelectListener = getVersionSelectListener();
+ mVersionSelectButton.setOnClickListener(versionSelectListener);
+ mDefaultVersion.setOnClickListener(versionSelectListener);
+
+ // Set up the icon change click listener
+ mInstanceIcon.setOnClickListener(v -> {
+ // Fill recommended size on click to ge the most up to date data
+ mRecommendedIconSize = Math.max(v.getWidth(), v.getHeight());
+ CropperUtils.startCropper(mCropperLauncher);
+ });
+
+ mSharedDataCheckbox.setOnCheckedChangeListener((v,checked) ->{
+ mInstance.sharedData = checked;
+ int text = R.string.instance_shared_data_off;
+ if(checked) text = R.string.instance_shared_data_on;
+ mSharedDataCheckbox.setText(text);
+ });
+
+ Instance selectedInstance = Instances.loadSelectedInstance();
+ Context context = view.getContext();
+ if(selectedInstance == null) {
+ Toast.makeText(context, R.string.no_instance, Toast.LENGTH_LONG).show();
+ getParentFragmentManager().popBackStack();
+ }else {
+ loadValues(selectedInstance, context);
+ }
+ }
+
+ private View.OnClickListener getControlSelectListener() {
+ return v -> {
+ Bundle bundle = new Bundle(3);
+ bundle.putBoolean(FileSelectorFragment.BUNDLE_SELECT_FOLDER, false);
+ bundle.putString(FileSelectorFragment.BUNDLE_ROOT_PATH, Tools.CTRLMAP_PATH);
+
+ Tools.swapFragment(requireActivity(),
+ FileSelectorFragment.class, FileSelectorFragment.TAG, bundle);
+ };
+ }
+
+ private View.OnClickListener getVersionSelectListener() {
+ return v -> VersionSelectorDialog.open(v.getContext(), false, (id, snapshot)-> mDefaultVersion.setText(id));
+ }
+
+ private static String nullToEmpty(String in) {
+ if(in == null) return "";
+ return in;
+ }
+
+ private void loadValues(@NonNull Instance instance, @NonNull Context context){
+ mInstance = instance;
+ mInstanceIcon.setImageDrawable(
+ InstanceIconProvider.fetchIcon(getResources(), instance)
+ );
+
+ // Runtime spinner
+ List runtimes = MultiRTUtils.getRuntimes();
+ int jvmIndex = -1;
+ if(instance.selectedRuntime != null) {
+ jvmIndex = runtimes.indexOf(new Runtime(instance.selectedRuntime));
+ }
+ mDefaultRuntime.setAdapter(new RTSpinnerAdapter(context, runtimes));
+ if(jvmIndex == -1) jvmIndex = runtimes.size() - 1;
+ mDefaultRuntime.setSelection(jvmIndex);
+
+ // Renderer spinner
+ int rendererIndex = mRenderNames.indexOf(instance.getLaunchRenderer());
+ if(rendererIndex == -1) {
+ rendererIndex = mDefaultRenderer.getAdapter().getCount() - 1;
+ }
+ mDefaultRenderer.setSelection(rendererIndex);
+
+ mDefaultVersion.setText(instance.versionId);
+ mDefaultJvmArgument.setText(nullToEmpty(instance.jvmArgs));
+ mDefaultName.setText(nullToEmpty(instance.name));
+ mDefaultControl.setText(mSelectedControlLayout == null ? nullToEmpty(instance.controlLayout) : mSelectedControlLayout);
+ mSharedDataCheckbox.setChecked(instance.sharedData);
+ }
+
+ private void bindViews(@NonNull View view){
+ mDefaultControl = view.findViewById(R.id.vprof_editor_ctrl_spinner);
+ mDefaultRuntime = view.findViewById(R.id.vprof_editor_spinner_runtime);
+ mDefaultRenderer = view.findViewById(R.id.vprof_editor_instance_renderer);
+ mDefaultVersion = view.findViewById(R.id.vprof_editor_version_spinner);
+
+ mDefaultName = view.findViewById(R.id.vprof_editor_instance_name);
+ mDefaultJvmArgument = view.findViewById(R.id.vprof_editor_jre_args);
+
+ mSaveButton = view.findViewById(R.id.vprof_editor_save_button);
+ mDeleteButton = view.findViewById(R.id.vprof_editor_delete_button);
+ mControlSelectButton = view.findViewById(R.id.vprof_editor_ctrl_button);
+ mVersionSelectButton = view.findViewById(R.id.vprof_editor_version_button);
+ mInstanceIcon = view.findViewById(R.id.vprof_editor_instance_icon);
+ mSharedDataCheckbox = view.findViewById(R.id.vprof_editor_data_checkbox_container);
+ }
+
+ private void save(){
+ //First, check for potential issues in the inputs
+ mInstance.versionId = mDefaultVersion.getText().toString();
+ mInstance.controlLayout = mDefaultControl.getText().toString();
+ mInstance.name = mDefaultName.getText().toString();
+ mInstance.jvmArgs = mDefaultJvmArgument.getText().toString();
+
+ if(mInstance.controlLayout.isEmpty()) mInstance.controlLayout = null;
+ if(mInstance.jvmArgs.isEmpty()) mInstance.jvmArgs = null;
+
+ Runtime selectedRuntime = (Runtime) mDefaultRuntime.getSelectedItem();
+ mInstance.selectedRuntime = (selectedRuntime.name.equals("") || selectedRuntime.versionString == null)
+ ? null : selectedRuntime.name;
+
+ if(mDefaultRenderer.getSelectedItemPosition() == mRenderNames.size()) mInstance.renderer = null;
+ else mInstance.renderer = mRenderNames.get(mDefaultRenderer.getSelectedItemPosition());
+
+ try {
+ mInstance.write();
+ }catch (IOException e) {
+ Tools.showErrorRemote(e);
+ }
+ }
+
+ @Override
+ public float getAspectRatio() {
+ return 1f;
+ }
+
+ @Override
+ public int getTargetMaxSide() {
+ return mRecommendedIconSize;
+ }
+
+ @Override
+ public void onCropped(Bitmap contentBitmap) {
+ mInstanceIcon.setImageBitmap(contentBitmap);
+ Log.i("bitmap", "w="+contentBitmap.getWidth() +" h="+contentBitmap.getHeight());
+ try {
+ mInstance.encodeNewIcon(contentBitmap);
+ }catch (IOException e) {
+ Tools.showErrorRemote(e);
+ }
+ }
+
+ @Override
+ public void onFailed(Exception exception) {
+ Tools.showErrorRemote(exception);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java
new file mode 100644
index 0000000000..3db486e051
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/InstancePickerFragment.java
@@ -0,0 +1,157 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.profiles.ProfileAdapter;
+import net.kdt.pojavlaunch.profiles.ProfileAdapterExtra;
+import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Shown in the right pane (landscape) when the user taps the instance spinner.
+ * Uses the same item_version_profile_layout + ProfileAdapter.setView() as the
+ * spinner popup, so icon sizing is identical. Adds a "New instance" row at top.
+ */
+public class InstancePickerFragment extends Fragment {
+
+ public static final String TAG = "InstancePickerFragment";
+
+ public InstancePickerFragment() {
+ super(R.layout.fragment_instance_picker);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ // Back button — delegates to activity so the right pane pops correctly
+ view.findViewById(R.id.instance_picker_back)
+ .setOnClickListener(v -> requireActivity().onBackPressed());
+
+ RecyclerView recycler = view.findViewById(R.id.instance_picker_recycler);
+ recycler.setLayoutManager(new LinearLayoutManager(requireContext()));
+
+ LauncherProfiles.load();
+ Map profiles = LauncherProfiles.mainProfileJson.profiles;
+ List keys = new ArrayList<>(profiles.keySet());
+ String selected = LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, "");
+
+ ProfileAdapter profileAdapter = new ProfileAdapter(new ProfileAdapterExtra[0]);
+
+ recycler.setAdapter(new InstanceAdapter(keys, profiles, profileAdapter, selected,
+ profileKey -> {
+ Fragment parentFrag = getParentFragment();
+ if (parentFrag instanceof MainMenuFragment) {
+ ((MainMenuFragment) parentFrag).selectInstance(profileKey);
+ }
+ },
+ () -> {
+ // "New instance" tapped — open profile type selector in right pane
+ Fragment parentFrag = getParentFragment();
+ if (parentFrag instanceof MainMenuFragment) {
+ ((MainMenuFragment) parentFrag).openChildPane(
+ ProfileTypeSelectFragment.class,
+ ProfileTypeSelectFragment.TAG, null);
+ } else {
+ Tools.swapFragment(requireActivity(),
+ ProfileTypeSelectFragment.class,
+ ProfileTypeSelectFragment.TAG, null);
+ }
+ }));
+ }
+
+ // ── Adapter ──────────────────────────────────────────────────────────────
+
+ interface OnInstanceSelected { void onSelected(String profileKey); }
+ interface OnCreateNew { void onCreate(); }
+
+ static class InstanceAdapter extends RecyclerView.Adapter {
+
+ private static final int VIEW_TYPE_CREATE = 0;
+ private static final int VIEW_TYPE_INSTANCE = 1;
+
+ private final List mKeys;
+ private final Map mProfiles;
+ private final ProfileAdapter mProfileAdapter; // used for rendering via setView
+ private String mSelectedKey;
+ private final OnInstanceSelected mOnSelect;
+ private final OnCreateNew mOnCreate;
+
+ InstanceAdapter(List keys, Map profiles,
+ ProfileAdapter profileAdapter, String selectedKey,
+ OnInstanceSelected onSelect, OnCreateNew onCreate) {
+ mKeys = keys;
+ mProfiles = profiles;
+ mProfileAdapter = profileAdapter;
+ mSelectedKey = selectedKey;
+ mOnSelect = onSelect;
+ mOnCreate = onCreate;
+ }
+
+ @Override
+ public int getItemViewType(int position) {
+ return position == 0 ? VIEW_TYPE_CREATE : VIEW_TYPE_INSTANCE;
+ }
+
+ @Override
+ public int getItemCount() {
+ return mKeys.size() + 1; // +1 for "New instance"
+ }
+
+ @NonNull
+ @Override
+ public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ // Use the exact same layout the spinner popup uses
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_version_profile_layout, parent, false);
+ return new VH(v);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull VH h, int position) {
+ if (getItemViewType(position) == VIEW_TYPE_CREATE) {
+ // Render the "New instance" extra via ProfileAdapter
+ ProfileAdapterExtra extra = new ProfileAdapterExtra(
+ 0,
+ R.string.create_profile,
+ h.itemView.getContext().getDrawable(R.drawable.ic_add));
+ mProfileAdapter.setViewExtra(h.itemView, extra);
+ h.itemView.setOnClickListener(v -> mOnCreate.onCreate());
+ return;
+ }
+
+ String key = mKeys.get(position - 1); // offset by 1 for create row
+ MinecraftProfile p = mProfiles.get(key);
+
+ // Let ProfileAdapter.setView() handle icon + label — identical to spinner popup
+ mProfileAdapter.setView(h.itemView, key, key.equals(mSelectedKey));
+
+ h.itemView.setOnClickListener(v -> {
+ String prev = mSelectedKey;
+ mSelectedKey = key;
+ notifyItemChanged(mKeys.indexOf(prev) + 1);
+ notifyItemChanged(position);
+ mOnSelect.onSelected(key);
+ });
+ }
+
+ static class VH extends RecyclerView.ViewHolder {
+ VH(@NonNull View v) { super(v); }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java
new file mode 100644
index 0000000000..e20fbeace0
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LegacyFabricInstallFragment.java
@@ -0,0 +1,11 @@
+package net.kdt.pojavlaunch.fragments;
+
+import net.kdt.pojavlaunch.modloaders.FabriclikeUtils;
+
+public class LegacyFabricInstallFragment extends FabriclikeInstallFragment {
+
+ public static final String TAG = "LegacyFabricInstallFragment";
+ public LegacyFabricInstallFragment() {
+ super(FabriclikeUtils.LEGACY_FABRIC_UTILS, TAG);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java
index 86a00e3759..e4e2e5d4f4 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch.fragments;
+import static net.kdt.pojavlaunch.Tools.hasOnlineProfile;
+
import android.content.Context;
import android.os.Bundle;
import android.view.View;
@@ -31,6 +33,10 @@ public LocalLoginFragment(){
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ // This is overkill but meh
+ if (!hasOnlineProfile()){
+ Tools.swapFragment(requireActivity(), MainMenuFragment.class, MainMenuFragment.TAG, null);
+ }
mUsernameEditText = view.findViewById(R.id.login_edit_email);
view.findViewById(R.id.login_button).setOnClickListener(v -> {
if(!checkEditText()) {
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 c760ecfa88..4988e02892 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
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch.fragments;
+import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog;
+import static net.kdt.pojavlaunch.Tools.hasOnlineProfile;
import static net.kdt.pojavlaunch.Tools.openPath;
import static net.kdt.pojavlaunch.Tools.shareLog;
@@ -7,9 +9,11 @@
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
+import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.Toast;
+import androidx.activity.OnBackPressedCallback;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
@@ -32,60 +36,326 @@ public class MainMenuFragment extends Fragment {
public static final String TAG = "MainMenuFragment";
private mcVersionSpinner mVersionSpinner;
+ private FrameLayout mRightPane;
+ private View mBottomBarBg; // stub — kept so mTaskCountListener check compiles
+ private View mPlayButton;
+ private View mEditProfileButton;
+ private View mBottomBar; // the single LinearLayout container for the whole bar
+ // Intercepts Back when the right pane has something above home
+ private OnBackPressedCallback mRightPaneBackCallback;
- public MainMenuFragment(){
+ // ─── Two-pane helpers ────────────────────────────────────────────────────
+
+ /** True when the two-pane landscape layout is active. */
+ private boolean isTwoPane() {
+ return mRightPane != null;
+ }
+
+ /**
+ * True when the right pane has a non-home fragment on the back stack.
+ * Used by LauncherActivity to decide gear = home vs gear = settings.
+ */
+ public boolean isRightPaneActive() {
+ return isTwoPane() && getChildFragmentManager().getBackStackEntryCount() > 0;
+ }
+
+ /**
+ * Pops one entry off the right pane back stack.
+ * Called from LauncherActivity.onBackPressed().
+ */
+ public void popRightPane() {
+ if (!isTwoPane()) return;
+ if (getChildFragmentManager().getBackStackEntryCount() > 0) {
+ getChildFragmentManager().popBackStack();
+ }
+ }
+
+ /**
+ * Pops everything off the right pane back stack so the home fragment shows again.
+ * Safe to call even if back stack is empty.
+ */
+ public void clearRightPane() {
+ if (!isTwoPane()) return;
+ int count = getChildFragmentManager().getBackStackEntryCount();
+ if (count > 0) {
+ getChildFragmentManager().popBackStack(
+ getChildFragmentManager().getBackStackEntryAt(0).getName(),
+ androidx.fragment.app.FragmentManager.POP_BACK_STACK_INCLUSIVE);
+ }
+ }
+
+ /** Shows/hides the entire bottom bar. GONE collapses it so right pane fills full height. */
+ private void setBottomBarVisible(boolean visible) {
+ if (mBottomBar != null)
+ mBottomBar.setVisibility(visible ? View.VISIBLE : View.GONE);
+ }
+
+ // Note: play button visibility during downloads is handled by the activity's
+ // ProgressLayout — we do not need a separate TaskCountListener here.
+ /**
+ * Called by InstancePickerFragment after the user taps an instance.
+ * Saves the selection, refreshes the spinner display, and pops back to home.
+ */
+ public void selectInstance(String profileKey) {
+ LauncherPreferences.DEFAULT_PREF.edit()
+ .putString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, profileKey)
+ .apply();
+ ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, profileKey);
+ clearRightPane();
+ if (mVersionSpinner != null) mVersionSpinner.reloadProfiles();
+ }
+
+ /** Called externally (e.g. ProfileEditorFragment) to refresh the spinner display. */
+ public void reloadSpinner() {
+ if (mVersionSpinner != null) mVersionSpinner.reloadProfiles();
+ }
+
+ /**
+ * Called by child fragments inside the right pane to navigate to another fragment
+ * within the pane (landscape) or full-screen (portrait).
+ * Use this instead of Tools.swapFragment(requireActivity(), ...) from child fragments.
+ */
+ public void openChildPane(Class extends Fragment> fragmentClass, String tag,
+ @Nullable Bundle args) {
+ openPane(fragmentClass, tag, args);
+ }
+
+ /**
+ * Returns true if the pane was used.
+ */
+ public boolean tryOpenInRightPane(Class extends Fragment> fragmentClass, String tag,
+ @Nullable Bundle args) {
+ if (!isTwoPane()) return false;
+ openPane(fragmentClass, tag, args);
+ return true;
+ }
+
+ /**
+ * Internal navigation: right pane in landscape, full-screen swap in portrait.
+ */
+ private void openPane(Class extends Fragment> fragmentClass, String tag,
+ @Nullable Bundle args) {
+ if (isTwoPane()) {
+ getChildFragmentManager()
+ .beginTransaction()
+ .setReorderingAllowed(true)
+ .replace(R.id.right_pane_container, fragmentClass, args, tag)
+ .addToBackStack(tag)
+ .commit();
+ } else {
+ Tools.swapFragment(requireActivity(), fragmentClass, tag, args);
+ }
+ }
+
+ // ─── Lifecycle ───────────────────────────────────────────────────────────
+
+ public MainMenuFragment() {
super(R.layout.fragment_launcher);
}
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ // Create the callback once. Lifecycle owner = this fragment, so it is
+ // automatically removed when the fragment is DESTROYED (not just view-destroyed).
+ mRightPaneBackCallback = new OnBackPressedCallback(false) {
+ @Override
+ public void handleOnBackPressed() {
+ // Guard: only act if view is still alive and back stack has entries
+ if (mRightPane == null) return;
+ if (getChildFragmentManager().getBackStackEntryCount() > 0) {
+ getChildFragmentManager().popBackStackImmediate();
+ }
+ }
+ };
+ requireActivity().getOnBackPressedDispatcher()
+ .addCallback(this, mRightPaneBackCallback);
+
+ // Only register the back-stack listener once per fragment instance.
+ // Using a member reference so we can remove it in onDestroyView if needed.
+ getChildFragmentManager().addOnBackStackChangedListener(mBackStackListener);
+ }
+
+ /** Keeps a stable reference so we never register it twice. */
+ private final androidx.fragment.app.FragmentManager.OnBackStackChangedListener
+ mBackStackListener = () -> {
+ mRightPaneBackCallback.setEnabled(isRightPaneActive());
+ if (!isTwoPane()) return;
+ // Show bottom bar ONLY on home (back stack empty). Hide on all other panes
+ // including instance picker (it has its own back button in the header).
+ boolean showBar = getChildFragmentManager().getBackStackEntryCount() == 0;
+ setBottomBarVisible(showBar);
+ };
+
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- Button mNewsButton = view.findViewById(R.id.news_button);
- Button mDiscordButton = view.findViewById(R.id.discord_button);
+ Button mNewsButton = view.findViewById(R.id.news_button);
+ Button mDiscordButton = view.findViewById(R.id.discord_button);
Button mCustomControlButton = view.findViewById(R.id.custom_control_button);
- Button mInstallJarButton = view.findViewById(R.id.install_jar_button);
- Button mShareLogsButton = view.findViewById(R.id.share_logs_button);
- Button mOpenDirectoryButton = view.findViewById(R.id.open_files_button);
+ Button mInstallJarButton = view.findViewById(R.id.install_jar_button);
+ Button mShareLogsButton = view.findViewById(R.id.share_logs_button);
+ Button mManageModsButton = view.findViewById(R.id.open_files_button);
+ Button mOpenDirectoryButton = view.findViewById(R.id.open_directory_button);
+ Button mModStoreButton = view.findViewById(R.id.mod_store_button);
- ImageButton mEditProfileButton = view.findViewById(R.id.edit_profile_button);
- Button mPlayButton = view.findViewById(R.id.play_button);
+ ImageButton mEditProfileBtn = view.findViewById(R.id.edit_profile_button);
+ Button mPlayBtn = view.findViewById(R.id.play_button);
mVersionSpinner = view.findViewById(R.id.mc_version_spinner);
- mNewsButton.setOnClickListener(v -> Tools.openURL(requireActivity(), Tools.URL_HOME));
- mDiscordButton.setOnClickListener(v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite)));
- mCustomControlButton.setOnClickListener(v -> startActivity(new Intent(requireContext(), CustomControlsActivity.class)));
- mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false));
- mInstallJarButton.setOnLongClickListener(v->{
- runInstallerWithConfirmation(true);
- return true;
- });
- mEditProfileButton.setOnClickListener(v -> mVersionSpinner.openProfileEditor(requireActivity()));
+ // Detect two-pane landscape layout
+ mRightPane = view.findViewById(R.id.right_pane_container);
+
+ // Bottom bar refs
+ mBottomBarBg = view.findViewById(R.id._background_display_view);
+ mPlayButton = mPlayBtn;
+ mEditProfileButton = mEditProfileBtn;
+ mBottomBar = view.findViewById(R.id.bottom_bar);
+
+ // Load home fragment into right pane.
+ // Check by fragment presence, not savedInstanceState, so rotation works correctly.
+ if (isTwoPane()) {
+ Fragment existing = getChildFragmentManager()
+ .findFragmentById(R.id.right_pane_container);
+ if (existing == null) {
+ getChildFragmentManager()
+ .beginTransaction()
+ .setReorderingAllowed(true)
+ .replace(R.id.right_pane_container, RightPaneHomeFragment.class, null,
+ RightPaneHomeFragment.TAG)
+ // NOT added to back stack — home is the base, not a destination
+ .commit();
+ }
+ }
- mPlayButton.setOnClickListener(v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true));
+ // ── Sidebar buttons that are hidden in landscape (stubs kept for safety) ──
+ // Wiki / Discord are moved to RightPaneHomeFragment in landscape;
+ // they stay in the sidebar on portrait via fragment_launcher.xml (no-land).
+ if (mNewsButton != null)
+ mNewsButton.setOnClickListener(
+ v -> Tools.openURL(requireActivity(), Tools.URL_HOME));
+ if (mDiscordButton != null)
+ mDiscordButton.setOnClickListener(
+ v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite)));
- mShareLogsButton.setOnClickListener((v) -> shareLog(requireContext()));
+ // Custom controls (always opens as Activity — can't be in the pane)
+ mCustomControlButton.setOnClickListener(v ->
+ startActivity(new Intent(requireContext(), CustomControlsActivity.class)));
- mOpenDirectoryButton.setOnClickListener((v)-> openPath(v.getContext(), getCurrentProfileDirectory(), false));
+ // Mod Store
+ if (mModStoreButton != null)
+ mModStoreButton.setOnClickListener(v ->
+ openPane(ModsSearchFragment.class, ModsSearchFragment.TAG, null));
+ // Execute .jar
+ if (hasOnlineProfile()) {
+ mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false));
+ mInstallJarButton.setOnLongClickListener(v -> {
+ runInstallerWithConfirmation(true);
+ return true;
+ });
+ } else {
+ mInstallJarButton.setOnClickListener(
+ v -> hasNoOnlineProfileDialog(requireActivity()));
+ }
- mNewsButton.setOnLongClickListener((v)->{
- Tools.swapFragment(requireActivity(), GamepadMapperFragment.class, GamepadMapperFragment.TAG, null);
- return true;
+ // Share logs
+ if (mShareLogsButton != null)
+ mShareLogsButton.setOnClickListener(v -> shareLog(requireContext()));
+
+ // Manage Mods
+ mManageModsButton.setOnClickListener(v ->
+ openPane(ManageModsFragment.class, ManageModsFragment.TAG, null));
+
+ // Open game directory
+ if (mOpenDirectoryButton != null) {
+ mOpenDirectoryButton.setOnClickListener(v -> {
+ if (Tools.isDemoProfile(v.getContext())) {
+ hasNoOnlineProfileDialog(getActivity(),
+ getString(R.string.demo_unsupported),
+ getString(R.string.change_account));
+ } else if (!hasOnlineProfile()) {
+ hasNoOnlineProfileDialog(requireActivity());
+ } else {
+ openPath(v.getContext(), getCurrentProfileDirectory(), false);
+ }
+ });
+ }
+
+ // Edit profile — open in right pane in landscape, full-screen in portrait
+ mEditProfileBtn.setOnClickListener(v -> {
+ if (isTwoPane()) {
+ openPane(net.kdt.pojavlaunch.fragments.ProfileEditorFragment.class,
+ net.kdt.pojavlaunch.fragments.ProfileEditorFragment.TAG, null);
+ } else {
+ mVersionSpinner.openProfileEditor(requireActivity());
+ }
});
+
+ // In landscape: tapping the spinner opens the instance picker in the right pane
+ if (isTwoPane()) {
+ mVersionSpinner.setOnClickListener(v ->
+ openPane(InstancePickerFragment.class, InstancePickerFragment.TAG, null));
+ }
+
+ // Force correct initial bar state BEFORE registering task listener,
+ // so the listener's immediate callback doesn't fight an unset visibility.
+ if (isTwoPane()) {
+ setBottomBarVisible(getChildFragmentManager().getBackStackEntryCount() == 0);
+ }
+
+ // Play button visibility during downloads handled by activity's ProgressLayout
+
+ // Play
+ mPlayBtn.setOnClickListener(
+ v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true));
+
+ // Long-press wiki → gamepad mapper (hidden feature)
+ if (mNewsButton != null)
+ mNewsButton.setOnLongClickListener(v -> {
+ Tools.swapFragment(requireActivity(), GamepadMapperFragment.class,
+ GamepadMapperFragment.TAG, null);
+ return true;
+ });
}
- private File getCurrentProfileDirectory() {
- String currentProfile = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null);
- if(!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW);
- LauncherProfiles.load();
- MinecraftProfile profileObject = LauncherProfiles.mainProfileJson.profiles.get(currentProfile);
- if(profileObject == null) return new File(Tools.DIR_GAME_NEW);
- return Tools.getGameDirPath(profileObject);
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ mRightPane = null;
+ mBottomBarBg = null;
+ mPlayButton = null;
+ mEditProfileButton = null;
+ mBottomBar = null;
+ getChildFragmentManager().removeOnBackStackChangedListener(mBackStackListener);
}
@Override
public void onResume() {
super.onResume();
- mVersionSpinner.reloadProfiles();
+ if (mVersionSpinner != null) {
+ mVersionSpinner.post(() -> {
+ if (mVersionSpinner != null) mVersionSpinner.reloadProfiles();
+ });
+ }
+ if (isTwoPane() && mBottomBar != null) {
+ // Post so this runs after any pending task-listener callbacks
+ // that might incorrectly hide the bar
+ final boolean showBar = getChildFragmentManager().getBackStackEntryCount() == 0;
+ mBottomBar.post(() -> setBottomBarVisible(showBar));
+ }
+ }
+
+ // ─── Private helpers ────────────────────────────────────────────────────
+
+ private File getCurrentProfileDirectory() {
+ String currentProfile = LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null);
+ if (!Tools.isValidString(currentProfile)) return new File(Tools.DIR_GAME_NEW);
+ LauncherProfiles.load();
+ MinecraftProfile profileObject =
+ LauncherProfiles.mainProfileJson.profiles.get(currentProfile);
+ if (profileObject == null) return new File(Tools.DIR_GAME_NEW);
+ return Tools.getGameDirPath(profileObject);
}
private void runInstallerWithConfirmation(boolean isCustomArgs) {
@@ -94,4 +364,4 @@ private void runInstallerWithConfirmation(boolean isCustomArgs) {
else
Toast.makeText(requireContext(), R.string.tasks_ongoing, Toast.LENGTH_LONG).show();
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java
new file mode 100644
index 0000000000..62a949adbd
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ManageModsFragment.java
@@ -0,0 +1,122 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.os.Bundle;
+import android.view.View;
+import android.widget.ImageButton;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.fragments.ModsSearchFragment;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.InstalledModAdapter;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.io.File;
+
+public class ManageModsFragment extends Fragment {
+
+ public static final String TAG = "ManageModsFragment";
+
+ public ManageModsFragment() {
+ super(R.layout.fragment_manage_mods);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ ImageButton backButton = view.findViewById(R.id.manage_mods_back);
+ ImageButton addButton = view.findViewById(R.id.manage_mods_add);
+ TextView title = view.findViewById(R.id.manage_mods_title);
+ RecyclerView recycler = view.findViewById(R.id.manage_mods_recycler);
+ View emptyState = view.findViewById(R.id.manage_mods_empty);
+
+ // Back — delegate to the activity which handles both portrait (pop activity stack)
+ // and landscape two-pane (pop right pane) in one reliable place.
+ backButton.setOnClickListener(v -> requireActivity().onBackPressed());
+
+ // Add → open mod store — stay in right pane if we're inside one
+ addButton.setOnClickListener(v ->
+ navigateToFragment(ModsSearchFragment.class, ModsSearchFragment.TAG));
+
+ // Title: "ProfileName - Mods"
+ String profileName = getCurrentProfileName();
+ title.setText(profileName.isEmpty()
+ ? getString(R.string.mcl_button_manage_mods)
+ : profileName + " - Mods");
+
+ // Build mod list
+ File modsDir = getModsDir();
+ InstalledModAdapter adapter = new InstalledModAdapter(modsDir, isEmpty -> {
+ recycler.setVisibility(isEmpty ? View.GONE : View.VISIBLE);
+ emptyState.setVisibility(isEmpty ? View.VISIBLE : View.GONE);
+ });
+
+ recycler.setLayoutManager(new LinearLayoutManager(requireContext()));
+ recycler.setAdapter(adapter);
+ }
+
+ // ── Helpers ─────────────────────────────────────────────────────────────
+
+ private String getCurrentProfileName() {
+ try {
+ String key = LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null);
+ if (key == null || key.isEmpty()) return "";
+ LauncherProfiles.load();
+ MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key);
+ if (profile == null) return "";
+ return profile.name != null ? profile.name : key;
+ } catch (Exception e) {
+ return "";
+ }
+ }
+
+ private File getModsDir() {
+ try {
+ String key = LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null);
+ if (key != null && !key.isEmpty()) {
+ LauncherProfiles.load();
+ MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key);
+ if (profile != null) {
+ File gameDir = Tools.getGameDirPath(profile);
+ return new File(gameDir, "mods");
+ }
+ }
+ } catch (Exception ignored) {}
+ return new File(Tools.DIR_GAME_NEW, "mods");
+ }
+
+ /** Go back — pops the parent's child stack synchronously when inside right pane. */
+ private void navigateBack() {
+ Fragment parent = getParentFragment();
+ if (parent != null) {
+ // Synchronous pop — no race condition with the view lifecycle
+ parent.getChildFragmentManager().popBackStackImmediate();
+ } else {
+ Tools.removeCurrentFragment(requireActivity());
+ }
+ }
+
+ /** Navigate to a fragment — stays inside the right pane when running as a child fragment. */
+ private void navigateToFragment(Class extends Fragment> fragmentClass, String tag) {
+ Fragment parent = getParentFragment();
+ if (parent != null) {
+ parent.getChildFragmentManager()
+ .beginTransaction()
+ .setReorderingAllowed(true)
+ .replace(R.id.right_pane_container, fragmentClass, null, tag)
+ .addToBackStack(tag)
+ .commit();
+ } else {
+ Tools.swapFragment(requireActivity(), fragmentClass, tag, null);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java
index b95d07a77c..8eefacbf28 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/MicrosoftLoginFragment.java
@@ -16,6 +16,7 @@
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
+import net.kdt.pojavlaunch.fragments.MainMenuFragment;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
@@ -106,8 +107,16 @@ public boolean shouldOverrideUrlLoading(WebView view, String url) {
// Should be captured by the activity to kill the fragment and get
ExtraCore.setValue(ExtraConstants.MICROSOFT_LOGIN_TODO, Uri.parse(url));
Toast.makeText(view.getContext(), "Login started !", Toast.LENGTH_SHORT).show();
- Tools.backToMainMenu(requireActivity());
-
+ // Navigate back to home — use right pane pop if inside MainMenuFragment
+ Fragment parent = getParentFragment();
+ while (parent != null && !(parent instanceof MainMenuFragment)) {
+ parent = parent.getParentFragment();
+ }
+ if (parent instanceof MainMenuFragment) {
+ ((MainMenuFragment) parent).clearRightPane();
+ } else {
+ Tools.backToMainMenu(requireActivity());
+ }
return true;
}
@@ -129,4 +138,4 @@ public void onPageFinished(WebView view, String url) {}
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java
index 1260cdde6c..7b2952d3be 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModVersionListFragment.java
@@ -17,7 +17,6 @@
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.extra.ExtraCore;
-import net.kdt.pojavlaunch.mirrors.DownloadMirror;
import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener;
import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy;
import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
@@ -106,7 +105,7 @@ public boolean onChildClick(ExpandableListView expandableListView, View view, in
Object forgeVersion = expandableListView.getExpandableListAdapter().getChild(i, i1);
ModloaderListenerProxy taskProxy = new ModloaderListenerProxy();
Runnable downloadTask = createDownloadTask(forgeVersion, taskProxy);
- setTaskProxy(taskProxy);
+ setTaskProxyValue(taskProxy);
taskProxy.attachListener(this);
mExpandableListView.setEnabled(false);
new Thread(downloadTask).start();
@@ -118,7 +117,7 @@ public void onDownloadFinished(File downloadedFile) {
Tools.runOnUiThread(()->{
Context context = requireContext();
getTaskProxy().detachListener();
- setTaskProxy(null);
+ deleteTaskProxy();
mExpandableListView.setEnabled(true);
// Read the comment in FabricInstallFragment.onDownloadFinished() to see how this works
getParentFragmentManager().popBackStackImmediate();
@@ -131,7 +130,7 @@ public void onDataNotAvailable() {
Tools.runOnUiThread(()->{
Context context = requireContext();
getTaskProxy().detachListener();
- setTaskProxy(null);
+ deleteTaskProxy();
mExpandableListView.setEnabled(true);
Tools.dialog(context,
context.getString(R.string.global_error),
@@ -144,15 +143,18 @@ public void onDownloadError(Exception e) {
Tools.runOnUiThread(()->{
Context context = requireContext();
getTaskProxy().detachListener();
- setTaskProxy(null);
+ deleteTaskProxy();
mExpandableListView.setEnabled(true);
Tools.showError(context, e);
});
}
- private void setTaskProxy(ModloaderListenerProxy proxy) {
+ private void setTaskProxyValue(ModloaderListenerProxy proxy) {
ExtraCore.setValue(mExtraTag, proxy);
}
+ private void deleteTaskProxy(){
+ ExtraCore.removeValue(mExtraTag);
+ }
private ModloaderListenerProxy getTaskProxy() {
return (ModloaderListenerProxy) ExtraCore.getValue(mExtraTag);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java
new file mode 100644
index 0000000000..d595c75fa4
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModpackCreateFragment.java
@@ -0,0 +1,59 @@
+package net.kdt.pojavlaunch.fragments;
+
+import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog;
+import static net.kdt.pojavlaunch.Tools.hasOnlineProfile;
+
+import android.app.Activity;
+import android.os.Bundle;
+import android.view.View;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import com.kdt.mcgui.MineButton;
+
+import net.kdt.pojavlaunch.BaseActivity;
+import net.kdt.pojavlaunch.LauncherActivity;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.contracts.OpenDocumentWithExtension;
+import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchFilters;
+
+public class ModpackCreateFragment extends Fragment {
+ public static final String TAG = "ModpackCreateFragment";
+ public ModpackCreateFragment() {
+ super(R.layout.fragment_create_modpack_profile);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ view.findViewById(R.id.button_browse_modpacks).setOnClickListener(v -> {
+ tryInstall(SearchModFragment.class, SearchModFragment.TAG);
+ });
+ view.findViewById(R.id.button_import_modpack).setOnClickListener(v -> {
+ Activity launcheractivity = requireActivity();
+ if (!(launcheractivity instanceof LauncherActivity))
+ throw new IllegalStateException("Cannot import modpack without LauncherActivity");
+ ((LauncherActivity) launcheractivity).modpackImportLauncher.launch(null);
+ });;
+ }
+
+ private void tryInstall(Class extends Fragment> fragmentClass, String tag){
+ if(!hasOnlineProfile()){
+ hasNoOnlineProfileDialog(requireActivity());
+ } else {
+ // Walk up to find MainMenuFragment
+ Fragment parent = getParentFragment();
+ while (parent != null && !(parent instanceof MainMenuFragment)) {
+ parent = parent.getParentFragment();
+ }
+ if (parent instanceof MainMenuFragment) {
+ ((MainMenuFragment) parent).openChildPane(fragmentClass, tag, null);
+ } else {
+ Tools.swapFragment(requireActivity(), fragmentClass, tag, null);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java
new file mode 100644
index 0000000000..f7b86d8538
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/ModsSearchFragment.java
@@ -0,0 +1,449 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.content.res.ColorStateList;
+import android.graphics.Color;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.widget.Toast;
+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 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.ModItemAdapter;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.CommonApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModpackApi;
+import net.kdt.pojavlaunch.modloaders.modpacks.api.ModrinthApi;
+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.Constants;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
+import net.kdt.pojavlaunch.profiles.VersionSelectorDialog;
+import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
+import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Searches and installs individual mods into the current instance's mods folder.
+ * - Version filter: when an MC version is selected, only versions matching it are shown.
+ * - Dependency dialog: shown before download, matching the ModBundle UI.
+ */
+public class ModsSearchFragment extends Fragment implements ModItemAdapter.SearchResultCallback {
+
+ public static final String TAG = "ModsSearchFragment";
+
+ private View mOverlay;
+ private float mOverlayTopCache;
+
+ 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 mModpackApi;
+ private final SearchFilters mSearchFilters;
+
+ public ModsSearchFragment() {
+ super(R.layout.fragment_mod_search);
+ mSearchFilters = new SearchFilters();
+ mSearchFilters.isModpack = false;
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ mModpackApi = new ModsInstallApi(context.getString(R.string.curseforge_api_key), mSearchFilters);
+ ((ModsInstallApi) mModpackApi).mActivityContext = context;
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ mModItemAdapter = new ModItemAdapter(getResources(), mModpackApi, 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) -> {
+ searchMods(mSearchEditText.getText().toString());
+ mSearchEditText.clearFocus();
+ return false;
+ });
+
+ mOverlay.post(() -> {
+ int overlayHeight = mOverlay.getHeight();
+ mRecyclerview.setPadding(
+ mRecyclerview.getPaddingLeft(),
+ mRecyclerview.getPaddingTop() + overlayHeight,
+ mRecyclerview.getPaddingRight(),
+ mRecyclerview.getPaddingBottom());
+ });
+
+ mFilterButton.setOnClickListener(v -> displayFilterDialog());
+ mSearchEditText.setHint(R.string.hint_search_mod);
+ searchMods(null);
+ }
+
+ @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_mod_error);
+ break;
+ case ERROR_NO_RESULTS:
+ mStatusTextView.setTextColor(mDefaultTextColor);
+ mStatusTextView.setText(R.string.search_mod_no_result);
+ break;
+ }
+ }
+
+ private void searchMods(String name) {
+ mSearchProgressBar.setVisibility(View.VISIBLE);
+ mSearchFilters.name = name == null ? "" : name;
+ mModItemAdapter.performSearchQuery(mSearchFilters);
+ }
+
+ private void displayFilterDialog() {
+ AlertDialog dialog = new AlertDialog.Builder(requireContext())
+ .setView(R.layout.dialog_mod_filters)
+ .create();
+
+ 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);
+ android.widget.Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner);
+
+ assert mSelectedVersion != null;
+ assert mSelectVersionButton != null;
+ assert mApplyButton != null;
+
+ // Set up loader spinner
+ if (mLoaderSpinner != null) {
+ String[] loaderLabels = {"Any loader", "Fabric", "Forge", "Quilt", "NeoForge"};
+ final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"};
+ android.widget.ArrayAdapter loaderAdapter = new android.widget.ArrayAdapter<>(
+ requireContext(), android.R.layout.simple_spinner_item, loaderLabels);
+ loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mLoaderSpinner.setAdapter(loaderAdapter);
+
+ // Restore current selection
+ String currentLoader = mSearchFilters.modLoader != null ? mSearchFilters.modLoader : "";
+ for (int i = 0; i < loaderValues.length; i++) {
+ if (loaderValues[i].equals(currentLoader)) {
+ mLoaderSpinner.setSelection(i);
+ break;
+ }
+ }
+
+ mSelectVersionButton.setOnClickListener(v ->
+ VersionSelectorDialog.open(v.getContext(), true,
+ (id, snapshot) -> mSelectedVersion.setText(id)));
+
+ mSelectedVersion.setText(mSearchFilters.mcVersion);
+
+ mApplyButton.setOnClickListener(v -> {
+ mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
+ int pos = mLoaderSpinner.getSelectedItemPosition();
+ mSearchFilters.modLoader = loaderValues[pos];
+ searchMods(mSearchEditText.getText().toString());
+ dialogInterface.dismiss();
+ });
+ } else {
+ // Fallback if spinner view not found
+ mSelectVersionButton.setOnClickListener(v ->
+ VersionSelectorDialog.open(v.getContext(), true,
+ (id, snapshot) -> mSelectedVersion.setText(id)));
+
+ mSelectedVersion.setText(mSearchFilters.mcVersion);
+
+ mApplyButton.setOnClickListener(v -> {
+ mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
+ searchMods(mSearchEditText.getText().toString());
+ dialogInterface.dismiss();
+ });
+ }
+ });
+
+ dialog.show();
+ }
+
+ // ── ModsInstallApi ────────────────────────────────────────────────────────
+
+ private static class ModsInstallApi extends CommonApi {
+
+ private final SearchFilters mFilters;
+ private final ModrinthApi mModrinthApi = new ModrinthApi();
+ private final Handler mMainHandler = new Handler(Looper.getMainLooper());
+ private Context mActivityContext;
+ private final net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi mCurseforgeApi;
+
+ ModsInstallApi(String curseforgeApiKey, SearchFilters filters) {
+ super(curseforgeApiKey);
+ mFilters = filters;
+ mCurseforgeApi = new net.kdt.pojavlaunch.modloaders.modpacks.api.CurseforgeApi(curseforgeApiKey);
+ }
+
+ /**
+ * Override getModDetails to filter versions by the selected MC version.
+ * Only versions matching the filter are shown in the version dropdown.
+ */
+ @Override
+ public ModDetail getModDetails(ModItem item) {
+ if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH) {
+ String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
+ ? mFilters.mcVersion : null;
+ String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty())
+ ? mFilters.modLoader : null;
+ return mModrinthApi.getModDetails(item, filterVer, filterLoader);
+ }
+ if (item.apiSource == net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_CURSEFORGE) {
+ String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
+ ? mFilters.mcVersion : null;
+ return mCurseforgeApi.getModDetails(item, filterVer);
+ }
+ return super.getModDetails(item);
+ }
+
+ @Override
+ public void handleInstallation(Context context, ModDetail modDetail, int selectedVersion) {
+ if (modDetail.isModpack) {
+ super.handleInstallation(context, modDetail, selectedVersion);
+ return;
+ }
+
+ String url = modDetail.versionUrls[selectedVersion];
+
+ // Check if this is a CF-restricted mod using the flag set during search
+ boolean isCfRestricted = modDetail.apiSource == Constants.SOURCE_CURSEFORGE
+ && (modDetail.isRestricted || url == null || url.isEmpty());
+
+ if (isCfRestricted) {
+ String cfUrl = (modDetail.websiteUrl != null && !modDetail.websiteUrl.isEmpty())
+ ? modDetail.websiteUrl
+ : "https://www.curseforge.com/minecraft/mc-mods/" + modDetail.id;
+ Context dialogCtx = mActivityContext != null ? mActivityContext : context;
+ mMainHandler.post(() ->
+ new AlertDialog.Builder(dialogCtx)
+ .setTitle(modDetail.title)
+ .setMessage("This mod restricts third-party downloads.\n\nDownload it manually from CurseForge and place it in your mods folder:\n\n" + cfUrl)
+ .setPositiveButton("Open CurseForge", (d, w) ->
+ Tools.openURL((android.app.Activity) dialogCtx, cfUrl))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show()
+ );
+ return;
+ }
+
+ if (url == null || url.isEmpty()) {
+ Tools.showErrorRemote(context, R.string.modpack_install_download_failed,
+ new IOException("No download URL available for this mod"));
+ return;
+ }
+
+ // Extract filename
+ String rawName = url.substring(url.lastIndexOf('/') + 1);
+ if (rawName.contains("?")) rawName = rawName.substring(0, rawName.indexOf('?'));
+ final String fileName = rawName.endsWith(".jar") ? rawName : rawName + ".jar";
+
+ // Check if this version has dependencies
+ String[] depIds = (modDetail.versionDependencyIds != null) ? modDetail.versionDependencyIds[selectedVersion] : null;
+ String[] depTypes = (modDetail.versionDependencyTypes != null) ? modDetail.versionDependencyTypes[selectedVersion] : null;
+
+ if (depIds == null || depIds.length == 0) {
+ // No deps — download directly
+ downloadMod(context, url, fileName, new String[0], new String[0]);
+ return;
+ }
+
+ // Fetch project names for all deps, then show dialog
+ String[] labels = new String[depIds.length];
+ final boolean[] checkedDefaults = new boolean[depIds.length];
+ AtomicInteger remaining = new AtomicInteger(depIds.length);
+
+ for (int i = 0; i < depIds.length; i++) {
+ final int idx = i;
+ final String type = (depTypes != null && idx < depTypes.length) ? depTypes[idx] : "required";
+ final String prefix = "required".equals(type) ? "Required: " : "Optional: ";
+ checkedDefaults[idx] = "required".equals(type);
+
+ final String projectId = depIds[idx];
+ PojavApplication.sExecutorService.execute(() -> {
+ // Fetch project name from Modrinth
+ String name = fetchProjectName(projectId);
+ labels[idx] = prefix + (name != null ? name : projectId);
+ if (remaining.decrementAndGet() == 0) {
+ mMainHandler.post(() -> showDepsDialog(context, url, fileName,
+ depIds, depTypes, labels, checkedDefaults));
+ }
+ });
+ }
+ }
+
+ private void showDepsDialog(Context context, String url, String fileName,
+ String[] depIds, String[] depTypes,
+ String[] labels, boolean[] checkedDefaults) {
+ // context here is getApplicationContext() from ModItemAdapter — no window token.
+ // Use the stored Activity reference instead.
+ Context dialogCtx = mActivityContext != null ? mActivityContext : context;
+ boolean[] selected = checkedDefaults.clone();
+
+ new AlertDialog.Builder(dialogCtx)
+ .setTitle(R.string.mod_deps_title)
+ .setMultiChoiceItems(labels, selected,
+ (dialog, which, isChecked) -> selected[which] = isChecked)
+ .setPositiveButton(R.string.mod_deps_install_selected, (d, w) -> {
+ List selectedIds = new ArrayList<>();
+ for (int i = 0; i < depIds.length; i++) {
+ if (selected[i]) selectedIds.add(depIds[i]);
+ }
+ downloadMod(context, url, fileName,
+ selectedIds.toArray(new String[0]), depTypes);
+ })
+ .setNeutralButton(R.string.mod_deps_install_without,
+ (d, w) -> downloadMod(context, url, fileName, new String[0], new String[0]))
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ private void downloadMod(Context context, String url, String fileName,
+ String[] depIds, String[] depTypes) {
+ File modsDir = getModsDir();
+ if (!modsDir.exists()) modsDir.mkdirs();
+
+ ProgressLayout.setProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.global_waiting);
+ PojavApplication.sExecutorService.execute(() -> {
+ try {
+ // Download main mod
+ DownloadUtils.downloadFile(url, new File(modsDir, fileName));
+
+ // Download selected dependencies
+ for (String depId : depIds) {
+ if (depId == null || depId.isEmpty()) continue;
+ downloadDependency(depId, modsDir);
+ }
+
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ Tools.runOnUiThread(() ->
+ Toast.makeText(context,
+ context.getString(R.string.mod_install_success, fileName),
+ Toast.LENGTH_LONG).show());
+ } catch (Exception e) {
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ Tools.showErrorRemote(context, R.string.modpack_install_download_failed, e);
+ }
+ });
+ }
+
+ private void downloadDependency(String projectId, File modsDir) {
+ // Fetch latest version for the current MC version/loader filter
+ try {
+ String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
+ ? mFilters.mcVersion : "";
+ String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty())
+ ? mFilters.modLoader : null;
+ ModItem depItem = new ModItem(
+ net.kdt.pojavlaunch.modloaders.modpacks.models.Constants.SOURCE_MODRINTH,
+ false, projectId, projectId, "", "");
+ ModDetail depDetail = mModrinthApi.getModDetails(depItem, filterVer.isEmpty() ? null : filterVer, filterLoader);
+ if (depDetail == null || depDetail.versionUrls == null || depDetail.versionUrls.length == 0) return;
+
+ String depUrl = depDetail.versionUrls[0];
+ String depName = depUrl.substring(depUrl.lastIndexOf('/') + 1);
+ if (depName.contains("?")) depName = depName.substring(0, depName.indexOf('?'));
+ if (!depName.endsWith(".jar")) depName += ".jar";
+
+ DownloadUtils.downloadFile(depUrl, new File(modsDir, depName));
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to download dependency " + projectId + ": " + e.getMessage());
+ }
+ }
+
+ private String fetchProjectName(String projectId) {
+ try {
+ net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler handler =
+ new net.kdt.pojavlaunch.modloaders.modpacks.api.ApiHandler("https://api.modrinth.com/v2");
+ com.google.gson.JsonObject obj = handler.get("project/" + projectId,
+ com.google.gson.JsonObject.class);
+ if (obj != null && obj.has("title")) return obj.get("title").getAsString();
+ } catch (Exception ignored) {}
+ return null;
+ }
+
+ private static File getModsDir() {
+ try {
+ String key = LauncherPreferences.DEFAULT_PREF
+ .getString(LauncherPreferences.PREF_KEY_CURRENT_PROFILE, null);
+ if (key != null && !key.isEmpty()) {
+ LauncherProfiles.load();
+ MinecraftProfile profile = LauncherProfiles.mainProfileJson.profiles.get(key);
+ if (profile != null) return new File(Tools.getGameDirPath(profile), "mods");
+ }
+ } catch (Exception ignored) {}
+ return new File(Tools.DIR_GAME_NEW, "mods");
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java
new file mode 100644
index 0000000000..51fb7bd7d0
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java
@@ -0,0 +1,115 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.content.Intent;
+import android.view.LayoutInflater;
+import android.widget.ExpandableListAdapter;
+
+import androidx.annotation.NonNull;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import net.kdt.pojavlaunch.JavaGUILauncherActivity;
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.modloaders.ForgeDownloadTask;
+import net.kdt.pojavlaunch.modloaders.ForgeUtils;
+import net.kdt.pojavlaunch.modloaders.ForgeVersionListHandler;
+import net.kdt.pojavlaunch.modloaders.ModloaderListenerProxy;
+import net.kdt.pojavlaunch.modloaders.NeoForgeDownloadTask;
+import net.kdt.pojavlaunch.modloaders.NeoForgeVersionListAdapter;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.List;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+public class NeoForgeInstallFragment extends ModVersionListFragment> {
+ public static final String TAG = "NeoForgeInstallFragment";
+ public NeoForgeInstallFragment() {
+ super(TAG);
+ }
+
+ private static final String NEOFORGE_METADATA_URL = "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml";
+
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ }
+
+ @Override
+ public int getTitleText() {
+ return R.string.neoforge_dl_select_version;
+ }
+
+ @Override
+ public int getNoDataMsg() {
+ return R.string.neoforge_dl_no_installer;
+ }
+
+ @Override
+ public List loadVersionList() {
+ return downloadNeoForgeVersions();
+ }
+
+ public static List downloadNeoForgeVersions() {
+ SAXParser saxParser;
+ try {
+ SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+ saxParser = parserFactory.newSAXParser();
+ }catch (SAXException | ParserConfigurationException e) {
+ e.printStackTrace();
+ // if we cant make a parser we might as well not even try to parse anything
+ return null;
+ }
+ try {
+ //of_test();
+ return DownloadUtils.downloadStringCached(NEOFORGE_METADATA_URL, "neoforge_versions", input -> {
+ try {
+ ForgeVersionListHandler handler = new ForgeVersionListHandler();
+ saxParser.parse(new InputSource(new StringReader(input)), handler);
+ return handler.getVersions();
+ // IOException is present here StringReader throws it only if the parser called close()
+ // sooner than needed, which is a parser issue and not an I/O one
+ }catch (SAXException | IOException e) {
+ throw new DownloadUtils.ParseException(e);
+ }
+ });
+ }catch (DownloadUtils.ParseException | IOException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+
+ @Override
+ public ExpandableListAdapter createAdapter(List versionList, LayoutInflater layoutInflater) {
+ return new NeoForgeVersionListAdapter(versionList, layoutInflater);
+ }
+
+ @Override
+ public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) {
+ return new NeoForgeDownloadTask(listenerProxy, (String) selectedVersion);
+ }
+
+ @Override
+ public void onDownloadFinished(Context context, File downloadedFile) {
+ Intent modInstallerStartIntent = new Intent(context, JavaGUILauncherActivity.class);
+ modInstallerStartIntent
+ .putExtra("javaArgs", "-jar "+downloadedFile.getAbsolutePath()+" --install-client")
+ .putExtra("openLogOutput", true);
+ context.startActivity(modInstallerStartIntent);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java
new file mode 100644
index 0000000000..40490e616e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoforgeInstallFragment.java
@@ -0,0 +1,31 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.modloaders.ForgelikeUtils;
+
+import git.artdeell.mojo.R;
+
+public class NeoforgeInstallFragment extends ForgelikeInstallFragment {
+ public static final String TAG = "NeoforgeInstallFragment";
+ public NeoforgeInstallFragment() {
+ super(ForgelikeUtils.NEOFORGE_UTILS, TAG);
+ }
+
+ @Override
+ public void onAttach(@NonNull Context context) {
+ super.onAttach(context);
+ }
+
+ @Override
+ public int getTitleText() {
+ return R.string.neoforge_dl_select_version;
+ }
+
+ @Override
+ public int getNoDataMsg() {
+ return R.string.neoforge_dl_no_installer;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java
new file mode 100644
index 0000000000..4a3a112bd3
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/OAuthFragment.java
@@ -0,0 +1,55 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.content.Context;
+import android.net.Uri;
+import android.widget.Toast;
+
+import androidx.fragment.app.FragmentActivity;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.extra.ExtraCore;
+
+import git.artdeell.mojo.R;
+
+public class OAuthFragment extends WebViewCompletionFragment {
+
+ private static final String QUERY_ERROR_NAME = "error";
+ private static final String QUERY_ERROR_DECRIPTION = "error_description";
+ private static final String QUERY_OAUTH_CODE = "code";
+ private static final String ERROR_ACCESS_DENIED = "access_denied";
+
+ private final String mExtraCoreConstant;
+
+ protected OAuthFragment(String mTrackedUrl, String mAuthUrl, String mExtraCoreConstant) {
+ super(mTrackedUrl, mAuthUrl);
+ this.mExtraCoreConstant = mExtraCoreConstant;
+ }
+
+ private void displayError(Context context, Uri uri) {
+ String errorMessage = uri.getQueryParameter(QUERY_ERROR_DECRIPTION);
+ if(errorMessage == null) errorMessage = uri.getQueryParameter(QUERY_ERROR_NAME);
+ if(errorMessage == null) errorMessage = getString(R.string.oauth_unknown_error);
+ Tools.dialog(context, getString(R.string.global_error), errorMessage);
+ }
+
+ @Override
+ protected void signalCompletion(String fullUrl) {
+ FragmentActivity activity = getActivity();
+ if(activity == null) return;
+ Uri uri = Uri.parse(fullUrl);
+ String error = uri.getQueryParameter(QUERY_ERROR_NAME);
+ String code = uri.getQueryParameter(QUERY_OAUTH_CODE);
+ if(code == null) {
+ activity.onBackPressed();
+ // Access denied - means the user exited out of the oauth dialog. Just leave the fragment
+ if(ERROR_ACCESS_DENIED.equals(error)) return;
+ // On other unknown errors, show a dialog
+ displayError(activity, uri);
+ return;
+ }
+ // Captured by the listener in the mcAccountSpinner
+ ExtraCore.setValue(mExtraCoreConstant, code);
+ Toast.makeText(activity, R.string.oauth_web_complete, Toast.LENGTH_SHORT).show();
+ Tools.backToMainMenu(activity);
+ }
+}
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 8b5a32b156..a48bc94c80 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
@@ -41,7 +41,7 @@ public ExpandableListAdapter createAdapter(OptiFineUtils.OptiFineVersions versio
@Override
public Runnable createDownloadTask(Object selectedVersion, ModloaderListenerProxy listenerProxy) {
- return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion, listenerProxy);
+ return new OptiFineDownloadTask((OptiFineUtils.OptiFineVersion) selectedVersion, listenerProxy, requireActivity());
}
@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 501b0147a7..bec95e7007 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
@@ -43,6 +43,8 @@
import java.util.Arrays;
import java.util.List;
+import androidx.fragment.app.Fragment;
+
public class ProfileEditorFragment extends Fragment implements CropperUtils.CropperListener{
public static final String TAG = "ProfileEditorFragment";
public static final String DELETED_PROFILE = "deleted_profile";
@@ -93,7 +95,14 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
mSaveButton.setOnClickListener(v -> {
ProfileIconCache.dropIcon(mProfileKey);
save();
- Tools.backToMainMenu(requireActivity());
+ Fragment parentFrag = getParentFragment();
+ if (parentFrag instanceof MainMenuFragment) {
+ MainMenuFragment mmf = (MainMenuFragment) parentFrag;
+ mmf.clearRightPane();
+ mmf.reloadSpinner();
+ } else {
+ Tools.backToMainMenu(requireActivity());
+ }
});
mDeleteButton.setOnClickListener(v -> {
@@ -101,10 +110,17 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
ProfileIconCache.dropIcon(mProfileKey);
LauncherProfiles.mainProfileJson.profiles.remove(mProfileKey);
LauncherProfiles.write();
- ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, DELETED_PROFILE);
+ ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, ProfileEditorFragment.DELETED_PROFILE);
+ }
+ Fragment parentFrag = getParentFragment();
+ if (parentFrag instanceof MainMenuFragment) {
+ MainMenuFragment mmf = (MainMenuFragment) parentFrag;
+ mmf.clearRightPane();
+ // Reload spinner now so deleted profile is gone immediately
+ mmf.reloadSpinner();
+ } else {
+ Tools.removeCurrentFragment(requireActivity());
}
-
- Tools.removeCurrentFragment(requireActivity());
});
@@ -164,12 +180,15 @@ private void loadValues(@NonNull String profile, @NonNull Context context){
if(mTempProfile == null){
mTempProfile = getProfile(profile);
}
+ // TODO: Remove this jank when it's not relevant anymore
+ // Shitty hack to make OSMZink smoothly transition into kopper
+ if ("vulkan_zink".equals(mTempProfile.pojavRendererName)) mTempProfile.pojavRendererName = "opengles3_desktopgl_zink_kopper";
mProfileIcon.setImageDrawable(
ProfileIconCache.fetchIcon(getResources(), mProfileKey, mTempProfile.icon)
);
// Runtime spinner
- List runtimes = MultiRTUtils.getRuntimes();
+ List runtimes = MultiRTUtils.getInstalledRuntimes();
int jvmIndex = runtimes.indexOf(new Runtime(""));
if (mTempProfile.javaDir != null) {
String selectedRuntime = mTempProfile.javaDir.substring(Tools.LAUNCHERPROFILES_RTPREFIX.length());
@@ -242,7 +261,9 @@ private void save(){
mTempProfile.lastVersionId = mDefaultVersion.getText().toString();
mTempProfile.controlFile = mDefaultControl.getText().toString();
mTempProfile.name = mDefaultName.getText().toString();
- mTempProfile.javaArgs = mDefaultJvmArgument.getText().toString();
+ mTempProfile.javaArgs = mDefaultJvmArgument.getText().toString()
+ .replaceAll("[\r\n]+", " ")
+ .trim();
mTempProfile.gameDir = mDefaultPath.getText().toString();
if(mTempProfile.controlFile.isEmpty()) mTempProfile.controlFile = null;
@@ -262,6 +283,22 @@ private void save(){
ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, mProfileKey);
}
+ @Override
+ public void onDestroyView() {
+ super.onDestroyView();
+ // Always drop the icon cache when leaving, even without saving,
+ // so the next reloadProfiles() re-fetches with correct bounds.
+ if (mProfileKey != null) {
+ ProfileIconCache.dropIcon(mProfileKey);
+ // Reload the spinner so the icon redraws at correct size immediately
+ // (covers Android back button path where reloadSpinner() isn't called explicitly)
+ Fragment parent = getParentFragment();
+ if (parent instanceof MainMenuFragment) {
+ ((MainMenuFragment) parent).reloadSpinner();
+ }
+ }
+ }
+
@Override
public void onCropped(Bitmap contentBitmap) {
mProfileIcon.setImageBitmap(contentBitmap);
@@ -294,4 +331,4 @@ public void onCropped(Bitmap contentBitmap) {
public void onFailed(Exception exception) {
Tools.showErrorRemote(exception);
}
-}
+}
\ No newline at end of file
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 9e409a41e1..60124891df 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
@@ -1,12 +1,17 @@
package net.kdt.pojavlaunch.fragments;
+import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog;
+import static net.kdt.pojavlaunch.Tools.hasOnlineProfile;
+
import android.os.Bundle;
import android.view.View;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
+import net.kdt.pojavlaunch.PojavProfile;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
@@ -15,27 +20,51 @@ public class ProfileTypeSelectFragment extends Fragment {
public ProfileTypeSelectFragment() {
super(R.layout.fragment_profile_type);
}
+ public ProfileTypeSelectFragment(int layout) {
+ super(layout);
+ }
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
- view.findViewById(R.id.vanilla_profile).setOnClickListener(v -> Tools.swapFragment(requireActivity(), ProfileEditorFragment.class,
- ProfileEditorFragment.TAG, new Bundle(1)));
-
- // NOTE: Special care needed! If you wll decide to add these to the back stack, please read
- // the comment in FabricInstallFragment.onDownloadFinished() and amend the code
- // in FabricInstallFragment.onDownloadFinished() and ModVersionListFragment.onDownloadFinished()
- view.findViewById(R.id.optifine_profile).setOnClickListener(v -> Tools.swapFragment(requireActivity(), OptiFineInstallFragment.class,
- OptiFineInstallFragment.TAG, null));
+ view.findViewById(R.id.vanilla_profile).setOnClickListener(v ->
+ navigateTo(ProfileEditorFragment.class, ProfileEditorFragment.TAG, new Bundle(1)));
+
+ view.findViewById(R.id.optifine_profile).setOnClickListener(v ->
+ tryInstall(OptiFineInstallFragment.class, OptiFineInstallFragment.TAG));
view.findViewById(R.id.modded_profile_fabric).setOnClickListener((v)->
- Tools.swapFragment(requireActivity(), FabricInstallFragment.class, FabricInstallFragment.TAG, null));
+ tryInstall(FabricInstallFragment.class, FabricInstallFragment.TAG));
view.findViewById(R.id.modded_profile_forge).setOnClickListener((v)->
- Tools.swapFragment(requireActivity(), ForgeInstallFragment.class, ForgeInstallFragment.TAG, null));
+ tryInstall(ForgeInstallFragment.class, ForgeInstallFragment.TAG));
+ view.findViewById(R.id.modded_profile_neoforge).setOnClickListener((v)->
+ tryInstall(NeoForgeInstallFragment.class, NeoForgeInstallFragment.TAG));
view.findViewById(R.id.modded_profile_modpack).setOnClickListener((v)->
- Tools.swapFragment(requireActivity(), SearchModFragment.class, SearchModFragment.TAG, null));
+ tryInstall(ModpackCreateFragment.class, ModpackCreateFragment.TAG));
view.findViewById(R.id.modded_profile_quilt).setOnClickListener((v)->
- Tools.swapFragment(requireActivity(), QuiltInstallFragment.class, QuiltInstallFragment.TAG, null));
+ tryInstall(QuiltInstallFragment.class, QuiltInstallFragment.TAG));
view.findViewById(R.id.modded_profile_bta).setOnClickListener((v)->
- Tools.swapFragment(requireActivity(), BTAInstallFragment.class, BTAInstallFragment.TAG, null));
+ tryInstall(BTAInstallFragment.class, BTAInstallFragment.TAG));
+ }
+
+ /** Navigate within right pane if inside MainMenuFragment, otherwise full-screen swap. */
+ protected void navigateTo(Class extends Fragment> cls, String tag, android.os.Bundle args) {
+ // Walk up to find MainMenuFragment (could be grandparent if nested)
+ Fragment parent = getParentFragment();
+ while (parent != null && !(parent instanceof MainMenuFragment)) {
+ parent = parent.getParentFragment();
+ }
+ if (parent instanceof MainMenuFragment) {
+ ((MainMenuFragment) parent).openChildPane(cls, tag, args);
+ } else {
+ Tools.swapFragment(requireActivity(), cls, tag, args);
+ }
+ }
+
+ private void tryInstall(Class extends Fragment> fragmentClass, String tag){
+ if(!hasOnlineProfile()){
+ hasNoOnlineProfileDialog(requireActivity());
+ } else {
+ navigateTo(fragmentClass, tag, null);
+ }
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java
new file mode 100644
index 0000000000..cceea28ac0
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/RightPaneHomeFragment.java
@@ -0,0 +1,79 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.graphics.drawable.Drawable;
+import android.os.Bundle;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.ImageView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+
+import java.io.File;
+
+/**
+ * Default content of the right pane in landscape two-pane mode.
+ * Shows a custom background (if set), otherwise a plain transparent pane.
+ * Wiki and Discord buttons are pinned at the top.
+ */
+public class RightPaneHomeFragment extends Fragment {
+
+ public static final String TAG = "RightPaneHomeFragment";
+ /** File path where the custom launcher background image is stored. */
+ public static final String CUSTOM_BG_PATH = Tools.DIR_DATA + "/custom_launcher_bg";
+
+ public RightPaneHomeFragment() {
+ super(R.layout.fragment_right_pane_home);
+ }
+
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ view.findViewById(R.id.news_button_pane).setOnClickListener(
+ v -> Tools.openURL(requireActivity(), Tools.URL_HOME));
+
+ view.findViewById(R.id.discord_button_pane).setOnClickListener(
+ v -> Tools.openURL(requireActivity(), getString(R.string.discord_invite)));
+
+ loadBackground(view);
+ }
+
+ /**
+ * Called after saving or removing a custom background so the pane
+ * refreshes without needing a full fragment recreate.
+ */
+ public void reloadBackground() {
+ View v = getView();
+ if (v != null) loadBackground(v);
+ }
+
+ private void loadBackground(@NonNull View view) {
+ ImageView wallpaper = view.findViewById(R.id.right_pane_wallpaper);
+ File bgFile = new File(CUSTOM_BG_PATH);
+ if (bgFile.exists()) {
+ Drawable d = Drawable.createFromPath(bgFile.getAbsolutePath());
+ if (d != null) {
+ wallpaper.setImageDrawable(d);
+ wallpaper.setScaleType(ImageView.ScaleType.CENTER_CROP);
+ wallpaper.setBackground(null);
+ wallpaper.setVisibility(View.VISIBLE);
+ return;
+ }
+ }
+ // No custom bg — show the gradient drawable as the pane background if gradient is on,
+ // otherwise stay transparent (root fragment_launcher bg shows through).
+ wallpaper.setImageDrawable(null);
+ TypedValue tv = new TypedValue();
+ view.getContext().getTheme().resolveAttribute(R.attr.bgMainDrawable, tv, true);
+ if (tv.resourceId != 0) {
+ wallpaper.setBackgroundResource(tv.resourceId);
+ wallpaper.setVisibility(View.VISIBLE);
+ } else {
+ wallpaper.setBackground(null);
+ wallpaper.setVisibility(View.GONE);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java
index e2b8ddc039..59fb74d8b9 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SearchModFragment.java
@@ -5,10 +5,12 @@
import android.graphics.Color;
import android.os.Bundle;
import android.view.View;
+import android.widget.ArrayAdapter;
import android.widget.Button;
import android.widget.EditText;
import android.widget.ImageButton;
import android.widget.ProgressBar;
+import android.widget.Spinner;
import android.widget.TextView;
import androidx.annotation.NonNull;
@@ -23,6 +25,10 @@
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.api.ModrinthApi;
+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.profiles.VersionSelectorDialog;
import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
@@ -61,7 +67,7 @@ public SearchModFragment(){
@Override
public void onAttach(@NonNull Context context) {
super.onAttach(context);
- modpackApi = new CommonApi(context.getString(R.string.curseforge_api_key));
+ modpackApi = new ModpackSearchApi(context.getString(R.string.curseforge_api_key), mSearchFilters);
}
@Override
@@ -148,26 +154,85 @@ private void displayFilterDialog() {
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);
+ Spinner mLoaderSpinner = dialog.findViewById(R.id.search_mod_loader_spinner);
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)));
+ // Set up loader spinner
+ if (mLoaderSpinner != null) {
+ String[] loaderLabels = {"Any loader", "Fabric", "Forge", "Quilt", "NeoForge"};
+ final String[] loaderValues = {"", "fabric", "forge", "quilt", "neoforge"};
+ ArrayAdapter loaderAdapter = new ArrayAdapter<>(
+ requireContext(), android.R.layout.simple_spinner_item, loaderLabels);
+ loaderAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
+ mLoaderSpinner.setAdapter(loaderAdapter);
+
+ // Restore current selection
+ String currentLoader = mSearchFilters.modLoader != null ? mSearchFilters.modLoader : "";
+ for (int i = 0; i < loaderValues.length; i++) {
+ if (loaderValues[i].equals(currentLoader)) {
+ mLoaderSpinner.setSelection(i);
+ break;
+ }
+ }
+
+ mSelectVersionButton.setOnClickListener(v ->
+ VersionSelectorDialog.open(v.getContext(), true,
+ (id, snapshot) -> mSelectedVersion.setText(id)));
+
+ mSelectedVersion.setText(mSearchFilters.mcVersion);
+
+ mApplyButton.setOnClickListener(v -> {
+ mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
+ int pos = mLoaderSpinner.getSelectedItemPosition();
+ mSearchFilters.modLoader = loaderValues[pos];
+ searchMods(mSearchEditText.getText().toString());
+ dialogInterface.dismiss();
+ });
+ } else {
+ mSelectVersionButton.setOnClickListener(v ->
+ VersionSelectorDialog.open(v.getContext(), true,
+ (id, snapshot) -> mSelectedVersion.setText(id)));
+ mSelectedVersion.setText(mSearchFilters.mcVersion);
+ mApplyButton.setOnClickListener(v -> {
+ mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
+ searchMods(mSearchEditText.getText().toString());
+ dialogInterface.dismiss();
+ });
+ }
+ });
- // Apply visually all the current settings
- mSelectedVersion.setText(mSearchFilters.mcVersion);
+ dialog.show();
+ }
- // Apply the new settings
- mApplyButton.setOnClickListener(v -> {
- mSearchFilters.mcVersion = mSelectedVersion.getText().toString();
- searchMods(mSearchEditText.getText().toString());
- dialogInterface.dismiss();
- });
- });
+ // ── ModpackSearchApi ──────────────────────────────────────────────────────
+ private static class ModpackSearchApi extends CommonApi {
+ private final SearchFilters mFilters;
+ private final ModrinthApi mModrinthApi = new ModrinthApi();
- dialog.show();
+ ModpackSearchApi(String curseforgeApiKey, SearchFilters filters) {
+ super(curseforgeApiKey);
+ mFilters = filters;
+ }
+
+ /**
+ * Override getModDetails so the version dropdown only shows versions
+ * matching the selected MC version and loader filter.
+ */
+ @Override
+ public ModDetail getModDetails(ModItem item) {
+ if (item.apiSource == Constants.SOURCE_MODRINTH) {
+ String filterVer = (mFilters.mcVersion != null && !mFilters.mcVersion.isEmpty())
+ ? mFilters.mcVersion : null;
+ String filterLoader = (mFilters.modLoader != null && !mFilters.modLoader.isEmpty())
+ ? mFilters.modLoader : null;
+ return mModrinthApi.getModDetails(item, filterVer, filterLoader);
+ }
+ // CurseForge: delegate normally (CF search already filters by version/loader)
+ return super.getModDetails(item);
+ }
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java
index 7814089885..5790dea758 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/SelectAuthFragment.java
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch.fragments;
+import static net.kdt.pojavlaunch.Tools.hasNoOnlineProfileDialog;
+
import android.os.Bundle;
import android.view.View;
import android.widget.Button;
@@ -23,7 +25,18 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat
Button mMicrosoftButton = view.findViewById(R.id.button_microsoft_authentication);
Button mLocalButton = view.findViewById(R.id.button_local_authentication);
- mMicrosoftButton.setOnClickListener(v -> Tools.swapFragment(requireActivity(), MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG, null));
- mLocalButton.setOnClickListener(v -> Tools.swapFragment(requireActivity(), LocalLoginFragment.class, LocalLoginFragment.TAG, null));
+ mMicrosoftButton.setOnClickListener(v -> navigateTo(MicrosoftLoginFragment.class, MicrosoftLoginFragment.TAG, null));
+ mLocalButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity(),
+ () -> navigateTo(LocalLoginFragment.class, LocalLoginFragment.TAG, null)));
+ }
+
+ /** Navigate within right pane if inside MainMenuFragment, otherwise full-screen swap. */
+ private void navigateTo(Class extends Fragment> cls, String tag, android.os.Bundle args) {
+ Fragment parent = getParentFragment();
+ if (parent instanceof MainMenuFragment) {
+ ((MainMenuFragment) parent).openChildPane(cls, tag, args);
+ } else {
+ Tools.swapFragment(requireActivity(), cls, tag, args);
+ }
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java
new file mode 100644
index 0000000000..b29762d2da
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/WebViewCompletionFragment.java
@@ -0,0 +1,129 @@
+package net.kdt.pojavlaunch.fragments;
+
+import android.annotation.SuppressLint;
+import android.graphics.Bitmap;
+import android.os.Bundle;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.webkit.CookieManager;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import androidx.annotation.NonNull;
+import androidx.fragment.app.Fragment;
+
+import git.artdeell.mojo.R;
+
+public abstract class WebViewCompletionFragment extends Fragment {
+ private final String mTrackedUrl;
+ private final String mAuthUrl;
+ private WebView mWebview;
+ // Technically the client is blank (or there is none) when the fragment is initialized
+ private boolean mBlankClient = true;
+ private boolean mIsCompleted = false;
+
+ protected WebViewCompletionFragment(String mTrackedUrl, String mAuthUrl) {
+ this.mTrackedUrl = mTrackedUrl;
+ this.mAuthUrl = mAuthUrl;
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
+ mWebview = (WebView) inflater.inflate(R.layout.fragment_microsoft_login, container, false);
+ setWebViewSettings();
+ if(savedInstanceState == null) startNewSession();
+ else restoreWebViewState(savedInstanceState);
+ return mWebview;
+ }
+
+ // WebView.restoreState() does not restore the WebSettings or the client, so set them there
+ // separately. Note that general state should not be altered here (aka no loading pages, no manipulating back/front lists),
+ // to avoid "undesirable side-effects"
+ @SuppressLint("SetJavaScriptEnabled")
+ private void setWebViewSettings() {
+ WebSettings settings = mWebview.getSettings();
+ settings.setJavaScriptEnabled(true);
+ mWebview.setWebViewClient(new WebViewTrackClient());
+ mBlankClient = false;
+ }
+
+ private void startNewSession() {
+ CookieManager.getInstance().removeAllCookies((b)->{
+ mWebview.clearHistory();
+ mWebview.clearCache(true);
+ mWebview.clearFormData();
+ mWebview.clearHistory();
+ mWebview.loadUrl(mAuthUrl);
+ });
+ }
+
+ private void restoreWebViewState(Bundle savedInstanceState) {
+ Log.i("MSAuthFragment","Restoring state...");
+ if(mWebview.restoreState(savedInstanceState) == null) {
+ Log.w("MSAuthFragment", "Failed to restore state, starting afresh");
+ // if, for some reason, we failed to restore our session,
+ // just start afresh
+ startNewSession();
+ }
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ // If we have switched to a blank client and haven't fully gone though the lifecycle callbacks to restore it,
+ // restore it here.
+ if(mBlankClient) mWebview.setWebViewClient(new WebViewTrackClient());
+ }
+
+ @Override
+ public void onSaveInstanceState(@NonNull Bundle outState) {
+ // Since the value cannot be null, just create a "blank" client. This is done to not let Android
+ // kill us if something happens after the state gets saved, when we can't do fragment transitions
+ mWebview.setWebViewClient(new WebViewClient());
+ // For some dumb reason state is saved even when Android won't actually destroy the activity.
+ // Let the fragment know that the client is blank so that we can restore it in onStart()
+ // (it was the earliest lifecycle call actually invoked in this case)
+ mBlankClient = true;
+ super.onSaveInstanceState(outState);
+ mWebview.saveState(outState);
+ }
+
+ /* Expose webview actions to others */
+ public boolean canGoBack(){ return mWebview.canGoBack();}
+ public void goBack(){ mWebview.goBack();}
+
+ /** Client to track when to sent the data to the launcher */
+ class WebViewTrackClient extends WebViewClient {
+
+ @Override
+ public boolean shouldOverrideUrlLoading(WebView view, String url) {
+ if(url.startsWith(mTrackedUrl)) {
+ internalSignalCompletion(url);
+ return true;
+ }
+
+ return super.shouldOverrideUrlLoading(view, url);
+ }
+
+ @Override
+ public void onPageStarted(WebView view, String url, Bitmap favicon) {}
+
+ @Override
+ public void onPageFinished(WebView view, String url) {
+ if(url.startsWith(mTrackedUrl)) {
+ internalSignalCompletion(url);
+ }
+ }
+ }
+
+ private void internalSignalCompletion(String fullUrl) {
+ if(mIsCompleted) return;
+ mIsCompleted = true;
+ signalCompletion(fullUrl);
+ }
+
+ protected abstract void signalCompletion(String fullUrl);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java
new file mode 100644
index 0000000000..589871bce7
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/DisplayInstance.java
@@ -0,0 +1,27 @@
+package net.kdt.pojavlaunch.instances;
+
+import java.io.File;
+
+public class DisplayInstance {
+ protected transient File mInstanceRoot;
+ public String name;
+ public String versionId;
+ public String icon;
+
+ protected void sanitize() {
+ sanitizeIcon();
+ }
+
+ protected DisplayInstance() {
+ }
+
+ protected File getInstanceIconLocation() {
+ return new File(mInstanceRoot, "icon.webp");
+ }
+
+ private void sanitizeIcon() {
+ if(!InstanceIconProvider.hasStaticIcon(icon)) {
+ icon = InstanceIconProvider.FALLBACK_ICON_NAME;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java
new file mode 100644
index 0000000000..244ea80bc9
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instance.java
@@ -0,0 +1,117 @@
+package net.kdt.pojavlaunch.instances;
+
+import android.graphics.Bitmap;
+import android.os.Build;
+import android.util.Log;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+public class Instance extends DisplayInstance {
+ public static final int ARGS_MODE_REPLACE = 0;
+ public static final int ARGS_MODE_MERGE_DEFAULT_FIRST = 1;
+ public static final int ARGS_MODE_MERGE_INSTANCE_FIRST = 2;
+ public static final int ARGS_MODE_LAST = ARGS_MODE_MERGE_INSTANCE_FIRST;
+
+ public static final String VERSION_LATEST_RELEASE = "latest_release";
+ public static final String VERSION_LATEST_SNAPSHOT = "latest_snapshot";
+
+ public InstanceInstaller installer;
+ public String renderer;
+ public String jvmArgs;
+ public int argsMode;
+ public String selectedRuntime;
+ public String controlLayout;
+ public boolean sharedData;
+
+ protected Instance() {
+ }
+
+ @Override
+ protected void sanitize() {
+ super.sanitize();
+ sanitizeArgs();
+ }
+
+ private void sanitizeArgs() {
+ if(argsMode > ARGS_MODE_LAST) {
+ argsMode = 0;
+ jvmArgs = null;
+ }
+ }
+
+ /**
+ * Write the current contents of the instance to persistent storage.
+ * @throws IOException in case of write errors
+ */
+ public void write() throws IOException {
+ JSONUtils.writeToFile(Instances.metadataLocation(mInstanceRoot), this);
+ }
+
+ /**
+ * Try to write the contents of the instance, ignore any exceptions
+ */
+ public void maybeWrite() {
+ try {
+ write();
+ }catch (IOException e) {
+ Log.e("Instance", "Failed to write",e);
+ }
+ }
+
+ /**
+ * Encode the Bitmap as the new profile icon with required encoding settings.
+ * @param bitmap the target bitmap
+ * @throws IOException in case of errors while storing the icon
+ */
+ public void encodeNewIcon(Bitmap bitmap) throws IOException {
+ try(FileOutputStream fileOutputStream = new FileOutputStream(getInstanceIconLocation())) {
+ bitmap.compress(
+ Build.VERSION.SDK_INT < Build.VERSION_CODES.R ?
+ // On Android < 30, there was no distinction between "lossy" and "lossless",
+ // and the type is picked by the quality parameter. We set the quality to 60.
+ // so it should be lossy,
+ Bitmap.CompressFormat.WEBP:
+ // On Android >= 30, we can explicitly specify that we want lossy compression
+ // with the visual quality of 60.
+ Bitmap.CompressFormat.WEBP_LOSSY,
+ 60,
+ fileOutputStream
+ );
+ }
+ }
+
+ public String getLaunchRenderer() {
+ if(Tools.isValidString(renderer)) return renderer;
+ return LauncherPreferences.PREF_RENDERER;
+ }
+
+ public String getLaunchArgs() {
+ if(!Tools.isValidString(jvmArgs)) return LauncherPreferences.PREF_CUSTOM_JAVA_ARGS;
+ switch (argsMode) {
+ case ARGS_MODE_REPLACE:
+ return jvmArgs;
+ case ARGS_MODE_MERGE_DEFAULT_FIRST:
+ return LauncherPreferences.PREF_CUSTOM_JAVA_ARGS + " " + jvmArgs;
+ case ARGS_MODE_MERGE_INSTANCE_FIRST:
+ return jvmArgs + " " + LauncherPreferences.PREF_CUSTOM_JAVA_ARGS;
+ default:
+ throw new RuntimeException("Unknown value for argsMode: "+argsMode);
+ }
+ }
+
+ public String getLaunchControls() {
+ if(!Tools.isValidString(controlLayout)) return LauncherPreferences.PREF_DEFAULTCTRL_PATH;
+ return Tools.CTRLMAP_PATH + "/" + controlLayout;
+ }
+
+ public File getGameDirectory() {
+ if(sharedData) return Instances.SHARED_DATA_DIRECTORY;
+ return mInstanceRoot;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java
new file mode 100644
index 0000000000..0f2e032068
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapter.java
@@ -0,0 +1,128 @@
+package net.kdt.pojavlaunch.instances;
+
+import android.graphics.Color;
+import android.graphics.drawable.Drawable;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseAdapter;
+
+import androidx.core.graphics.ColorUtils;
+
+import git.artdeell.mojo.R;
+
+import net.kdt.pojavlaunch.Tools;
+
+import fr.spse.extended_view.ExtendedTextView;
+
+/*
+ * Adapter for listing launcher profiles in a Spinner
+ */
+public class InstanceAdapter extends BaseAdapter {
+ private Instances mInstances;
+ private int mSelectionIndex;
+ private final InstanceAdapterExtra[] mExtraEntires;
+
+
+ public InstanceAdapter(InstanceAdapterExtra[] extraEntries) {
+ if(extraEntries == null) extraEntries = new InstanceAdapterExtra[0];
+ mExtraEntires = extraEntries;
+ }
+ /**
+ * @return how much entries (both instances and extra adapter entries) are in the adapter right now
+ */
+ @Override
+ public int getCount() {
+ if(mInstances == null) return mExtraEntires.length;
+ return mInstances.list.size() + mExtraEntires.length;
+ }
+ /**
+ * Gets the adapter entry at a given index
+ * @param position index to retrieve
+ * @return Instance, ProfileAdapterExtra or null
+ */
+ @Override
+ public Object getItem(int position) {
+ if(mInstances == null) return mExtraEntires[position];
+ int instanceListSize = mInstances.list.size();
+ int extraPosition = position - instanceListSize;
+ if(position < instanceListSize) {
+ return mInstances.list.get(position);
+ }else if(extraPosition >= 0 && extraPosition < mExtraEntires.length){
+ return mExtraEntires[extraPosition];
+ }
+ return null;
+ }
+
+ @Override
+ public long getItemId(int position) {
+ return position;
+ }
+
+ @Override
+ public View getView(int position, View convertView, ViewGroup parent) {
+ View v = convertView;
+ if (v == null) v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_version_profile_layout,parent,false);
+ setView(v, position, true);
+ return v;
+ }
+
+ public void setViewInstance(View v, DisplayInstance i, int idx, boolean displaySelection) {
+ ExtendedTextView extendedTextView = (ExtendedTextView) v;
+
+ //MinecraftProfile minecraftProfile = mProfiles.get(nm);
+ //if(minecraftProfile == null) minecraftProfile = dummy;
+ Drawable cachedIcon = InstanceIconProvider.fetchIcon(v.getResources(), i);
+ extendedTextView.setCompoundDrawablesRelative(cachedIcon, null, extendedTextView.getCompoundsDrawables()[2], null);
+
+ // Historically, the profile name "New" was hardcoded as the default profile name
+ // We consider "New" the same as putting no name at all
+
+ String profileName = Tools.validOrNullString(i.name);
+ String versionName = Tools.validOrNullString(i.versionId);
+
+ if (Instance.VERSION_LATEST_RELEASE.equalsIgnoreCase(versionName))
+ versionName = v.getContext().getString(R.string.profiles_latest_release);
+ else if (Instance.VERSION_LATEST_SNAPSHOT.equalsIgnoreCase(versionName))
+ versionName = v.getContext().getString(R.string.profiles_latest_snapshot);
+
+ if (versionName == null && profileName != null)
+ extendedTextView.setText(profileName);
+ else if (versionName != null && profileName == null)
+ extendedTextView.setText(versionName);
+ else extendedTextView.setText(String.format("%s - %s", profileName, versionName));
+
+ // Set selected background if needed
+ if(idx == mSelectionIndex && displaySelection) {
+ extendedTextView.setBackgroundColor(ColorUtils.setAlphaComponent(Color.WHITE, 60));
+ }else {
+ extendedTextView.setBackgroundColor(Color.TRANSPARENT);
+ }
+ }
+
+ public void setViewExtra(View v, InstanceAdapterExtra extra) {
+ ExtendedTextView extendedTextView = (ExtendedTextView) v;
+ extendedTextView.setCompoundDrawablesRelative(extra.icon, null, extendedTextView.getCompoundsDrawables()[2], null);
+ extendedTextView.setText(extra.name);
+ extendedTextView.setBackgroundColor(Color.TRANSPARENT);
+ }
+
+ public void setView(View v, int index, boolean displaySelection) {
+ Object object = getItem(index);
+ if(object instanceof DisplayInstance) {
+ setViewInstance(v, (DisplayInstance) object, index, displaySelection);
+ }else if(object instanceof InstanceAdapterExtra) {
+ setViewExtra(v, (InstanceAdapterExtra) object);
+ }
+ }
+
+ public void applySelectionIndex(int index) {
+ mSelectionIndex = index;
+ }
+
+ public void applyInstances(Instances instances) {
+ mInstances = instances;
+ mSelectionIndex = instances.selectedIndex;
+ notifyDataSetChanged();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java
new file mode 100644
index 0000000000..46fa5bf8de
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceAdapterExtra.java
@@ -0,0 +1,15 @@
+package net.kdt.pojavlaunch.instances;
+
+import android.graphics.drawable.Drawable;
+
+public class InstanceAdapterExtra {
+ public final int id;
+ public final int name;
+ public final Drawable icon;
+
+ public InstanceAdapterExtra(int id, int name, Drawable icon) {
+ this.id = id;
+ this.name = name;
+ this.icon = icon;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java
new file mode 100644
index 0000000000..cdbf86c9ba
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceIconProvider.java
@@ -0,0 +1,109 @@
+package net.kdt.pojavlaunch.instances;
+
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.res.ResourcesCompat;
+
+import git.artdeell.mojo.R;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Objects;
+
+public class InstanceIconProvider {
+ public static final String FALLBACK_ICON_NAME = "default";
+ private static final Map sIconCache = new HashMap<>();
+ private static final Map sStaticIconCache = new HashMap<>();
+ private static final Map sStaticIcons = new HashMap<>();
+
+ static {
+ sStaticIcons.put("default", R.drawable.ic_mojo_full);
+ sStaticIcons.put("fabric", R.drawable.ic_fabric);
+ sStaticIcons.put("quilt", R.drawable.ic_quilt);
+ sStaticIcons.put("forge", R.drawable.ic_forge);
+ sStaticIcons.put("neoforge", R.drawable.ic_neoforge);
+ }
+
+ /**
+ * Fetch an icon from the cache, or load it if it's not cached.
+ * @param resources the Resources object, used for creating drawables
+ * @param instance the instance
+ * @return an icon drawable
+ */
+ public static @NonNull Drawable fetchIcon(Resources resources, @NonNull DisplayInstance instance) {
+ int identityHashCode = System.identityHashCode(instance);
+
+ Drawable cachedIcon = sIconCache.get(identityHashCode);
+ if(cachedIcon != null) return cachedIcon;
+
+ Drawable instanceIcon = fetchInstanceFileIcon(resources, identityHashCode, instance.getInstanceIconLocation());
+ if(instanceIcon != null) return instanceIcon;
+
+ return fetchStaticIcon(resources, identityHashCode, instance.icon);
+ }
+
+ /**
+ * Drop an icon from the icon cache. When dropped, it's Drawable will be re-read from the
+ * instance icon file (or re-fetched from the static cache)
+ * @param key the instance
+ */
+ public static void dropIcon(@NonNull Instance key) {
+ sIconCache.remove(System.identityHashCode(key));
+ }
+
+ private static Drawable fetchInstanceFileIcon(Resources resources, int identityHash, File iconLocation) {
+ if(!iconLocation.isFile() || !iconLocation.canRead()) return null;
+ Bitmap iconBitmap = BitmapFactory.decodeFile(iconLocation.getAbsolutePath());
+ if(iconBitmap == null) return null;
+ Drawable iconDrawable = new BitmapDrawable(resources, iconBitmap);
+ sIconCache.put(identityHash, iconDrawable);
+ return iconDrawable;
+ }
+
+ private static Drawable fetchStaticIcon(Resources resources, int identityHash, String icon) {
+ Drawable staticIcon = sStaticIconCache.get(icon);
+ if(staticIcon == null) {
+ if(icon != null) staticIcon = getStaticIcon(resources, icon);
+ if(staticIcon == null) staticIcon = fetchFallbackIcon(resources);
+ sStaticIconCache.put(icon, staticIcon);
+ }
+ sIconCache.put(identityHash, staticIcon);
+ return staticIcon;
+ }
+
+ private static @NonNull Drawable fetchFallbackIcon(Resources resources) {
+ Drawable fallbackIcon = sStaticIconCache.get(FALLBACK_ICON_NAME);
+ if(fallbackIcon == null) {
+ fallbackIcon = Objects.requireNonNull(getStaticIcon(resources, FALLBACK_ICON_NAME));
+ sStaticIconCache.put(FALLBACK_ICON_NAME, fallbackIcon);
+ }
+ return fallbackIcon;
+ }
+
+ private static Drawable getStaticIcon(Resources resources, @NonNull String icon) {
+ int staticIconResource = getStaticIconResource(icon);
+ if(staticIconResource == -1) return null;
+ return ResourcesCompat.getDrawable(resources, staticIconResource, null);
+ }
+
+ private static int getStaticIconResource(String icon) {
+ Integer iconResource = sStaticIcons.get(icon);
+ if(iconResource == null) return -1;
+ return iconResource;
+ }
+
+ /**
+ * Check whether the icon under the specified name is a static icon available in the provider.
+ * @param name static icon name to check
+ * @return whether the icon is available or not
+ */
+ public static boolean hasStaticIcon(String name) {
+ return sStaticIcons.containsKey(name);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java
new file mode 100644
index 0000000000..8d661f124b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceInstaller.java
@@ -0,0 +1,179 @@
+package net.kdt.pojavlaunch.instances;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.res.AssetManager;
+import android.os.Bundle;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.JavaGUILauncherActivity;
+import net.kdt.pojavlaunch.LauncherActivity;
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.extra.ExtraConstants;
+import net.kdt.pojavlaunch.extra.ExtraCore;
+import net.kdt.pojavlaunch.instances.profcompat.ProfileWatcher;
+import net.kdt.pojavlaunch.lifecycle.ContextExecutor;
+import net.kdt.pojavlaunch.lifecycle.ContextExecutorTask;
+import net.kdt.pojavlaunch.modloaders.OFDownloadPageScraper;
+import net.kdt.pojavlaunch.progresskeeper.DownloaderProgressWrapper;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+import net.kdt.pojavlaunch.utils.NotificationUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import git.artdeell.mojo.R;
+
+public class InstanceInstaller implements ContextExecutorTask {
+ private static final File sLastInstallInfo = new File(Tools.DIR_CACHE, "last_installer.json");
+
+ private static final String[] TRUSTED_URLS = new String[] {
+ "https://maven.neoforged.net/releases/net/neoforged/neoforge/",
+ "https://maven.minecraftforge.net/net/minecraftforge/forge/",
+ "https://optifine.net/adloadx"
+ };
+
+ public String installerJar;
+ private transient File installerJarFile;
+ private transient String mTransformedUrl;
+ public List commandLineArgs;
+ public String installerUrlTransformer;
+ public String installerDownloadUrl;
+ public String installerSha1;
+
+ private File installerJar() {
+ if(installerJarFile == null) return installerJarFile = new File(installerJar);
+ return installerJarFile;
+ }
+
+ private String installerDownloadUrl() throws IOException{
+ if(mTransformedUrl != null) return mTransformedUrl;
+ String newUrl;
+ if ("optifine".equals(installerUrlTransformer)) {
+ newUrl = OFDownloadPageScraper.run(installerDownloadUrl);
+ }else {
+ newUrl = installerDownloadUrl;
+ }
+ mTransformedUrl = newUrl;
+ return newUrl;
+ }
+
+ private void writeLastInstaller() throws IOException {
+ JSONUtils.writeToFile(sLastInstallInfo, this);
+ }
+
+ public void threadedStart() throws IOException {
+ try {
+ final byte[] buffer = new byte[8192];
+ final DownloaderProgressWrapper wrapper = new DownloaderProgressWrapper(
+ R.string.mcl_launch_downloading_progress, ProgressLayout.INSTANCE_INSTALL
+ );
+ wrapper.extraString = installerJar().getName();
+ DownloadUtils.ensureSha1(installerJar(), installerSha1, ()->{
+ DownloadUtils.downloadFileMonitored(installerDownloadUrl(), installerJar(), buffer, wrapper);
+ return null;
+ });
+ ContextExecutor.execute(this);
+ } finally {
+ ProgressLayout.clearProgress(ProgressLayout.INSTANCE_INSTALL);
+ }
+ }
+
+ public static void postInstallCheck(AssetManager assetManager) throws IOException {
+ if(!sLastInstallInfo.exists() || !sLastInstallInfo.isFile()) return;
+ InstanceInstaller lastInstaller = JSONUtils.readFromFile(sLastInstallInfo, InstanceInstaller.class);
+ boolean ignored = lastInstaller.installerJar().delete();
+ if(!sLastInstallInfo.delete()) throw new IOException("Failed to delete mod installer info");
+ String targetVersionId = ProfileWatcher.consumePendingVersion(assetManager);
+ if(targetVersionId == null) return;
+ for(Instance instance : Instances.loadAllInstances()) {
+ if(!lastInstaller.equals(instance.installer)) continue;
+ instance.installer = null;
+ instance.versionId = targetVersionId;
+ instance.write();
+ }
+ ExtraCore.setValue(ExtraConstants.REFRESH_VERSION_SPINNER, null);
+ }
+
+ public static void postInstallCheck(Context context) {
+ try {
+ InstanceInstaller.postInstallCheck(context.getAssets());
+ }catch (Exception e) {
+ Tools.showError(context, e);
+ if (sLastInstallInfo.isFile()) {
+ boolean ignored = sLastInstallInfo.delete();
+ }
+ }
+ }
+
+ public void start() {
+ ProgressLayout.setProgress(ProgressLayout.INSTANCE_INSTALL, 0);
+ PojavApplication.sExecutorService.execute(()->{
+ try {
+ threadedStart();
+ }catch (Exception e) {
+ Tools.showErrorRemote(e);
+ }
+ });
+ }
+
+ @Override
+ public boolean equals(Object object) {
+ if (this == object) return true;
+ if (!(object instanceof InstanceInstaller)) return false;
+ InstanceInstaller that = (InstanceInstaller) object;
+ return Objects.equals(installerJar, that.installerJar) &&
+ Objects.equals(commandLineArgs, that.commandLineArgs) &&
+ Objects.equals(installerDownloadUrl, that.installerDownloadUrl) &&
+ Objects.equals(installerUrlTransformer, that.installerUrlTransformer) &&
+ Objects.equals(installerSha1, that.installerSha1);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(installerJar, commandLineArgs, installerDownloadUrl, installerUrlTransformer, installerSha1);
+ }
+
+ private boolean isTrustedInstaller() {
+ for(String frontTrusted : TRUSTED_URLS) {
+ if(installerDownloadUrl.startsWith(frontTrusted)) return true;
+ }
+ return false;
+ }
+
+ @Override
+ public void executeWithActivity(Activity activity) {
+ try {
+ ProfileWatcher.installDefaultProfiles(activity.getAssets());
+ writeLastInstaller();
+ }catch (Exception e) {
+ Tools.showError(activity, e);
+ return;
+ }
+ Intent intent = new Intent(activity, JavaGUILauncherActivity.class);
+ Bundle extras = new Bundle();
+ extras.putStringArrayList("javaArgs", new ArrayList<>(commandLineArgs));
+ extras.putString("modPath", installerJar);
+ extras.putBoolean("trusted", isTrustedInstaller());
+ intent.putExtras(extras);
+ activity.startActivity(intent);
+ }
+
+ @Override
+ public void executeWithApplication(Context context) {
+ Tools.runOnUiThread(() -> NotificationUtils.sendBasicNotification(context,
+ R.string.modpack_install_notification_title,
+ R.string.modpack_install_notification_success,
+ new Intent(context, LauncherActivity.class),
+ NotificationUtils.PENDINGINTENT_CODE_DOWNLOAD_SERVICE,
+ NotificationUtils.NOTIFICATION_ID_DOWNLOAD_LISTENER
+ ));
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java
new file mode 100644
index 0000000000..6db74a262f
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/InstanceSetter.java
@@ -0,0 +1,5 @@
+package net.kdt.pojavlaunch.instances;
+
+public interface InstanceSetter {
+ void setInstanceProperties(Instance instance);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java
new file mode 100644
index 0000000000..f9d11163be
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/Instances.java
@@ -0,0 +1,193 @@
+package net.kdt.pojavlaunch.instances;
+
+import com.google.gson.JsonSyntaxException;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.FileUtils;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+
+public class Instances {
+ private static final File sInstancePath = new File(Tools.DIR_GAME_HOME, "instances");
+ public static final File SHARED_DATA_DIRECTORY = new File(Tools.DIR_GAME_HOME, "shared_dir");
+
+ public final List list;
+ public final int selectedIndex;
+
+ private Instances(List instances, int selectedIndex) {
+ this.list = instances;
+ this.selectedIndex = selectedIndex;
+ }
+
+ private static T read(File instanceRoot, Class tClass) {
+ try {
+ T instance = JSONUtils.readFromFile(metadataLocation(instanceRoot), tClass);
+ if(instance == null) return null;
+ instance.mInstanceRoot = instanceRoot;
+ return instance;
+ }catch (IOException | JsonSyntaxException e) {
+ return null;
+ }
+ }
+
+ protected static File metadataLocation(File instanceDir) {
+ return new File(instanceDir, "mojo_instance.json");
+ }
+
+ private static File selectedInstanceLocation() {
+ String directoryName = LauncherPreferences.DEFAULT_PREF.getString(LauncherPreferences.PREF_KEY_CURRENT_INSTANCE, "");
+ File instanceRoot = new File(sInstancePath, directoryName);
+ if(!metadataLocation(instanceRoot).exists()) return null;
+ return instanceRoot;
+ }
+
+ private static boolean filterInstanceDirectories(File instanceDir) {
+ if(!instanceDir.canRead() || !instanceDir.canWrite()) return false;
+ if(!instanceDir.isDirectory()) return false;
+ File instanceMetadata = metadataLocation(instanceDir);
+ if(!instanceMetadata.isFile()) return false;
+ return instanceMetadata.canRead();
+ }
+
+ private static List loadInstances(Class tClass, int[] selectionDst) throws IOException {
+ synchronized (sInstancePath) {
+ FileUtils.ensureDirectory(sInstancePath);
+ }
+ File[] instanceDirectories = sInstancePath.listFiles(Instances::filterInstanceDirectories);
+ if(instanceDirectories == null) throw new IOException("Failed to enumerate instances");
+ File selectedInstanceLocation = selectionDst != null ? selectedInstanceLocation() : null;
+ ArrayList instances = new ArrayList<>(instanceDirectories.length);
+
+ for(File instanceDir : instanceDirectories) {
+ T instance = read(instanceDir, tClass);
+
+ if(instance == null) continue;
+ instance.sanitize();
+ instances.add(instance);
+
+ if(selectionDst != null && instanceDir.equals(selectedInstanceLocation)) {
+ selectionDst[0] = instances.size() - 1;
+ }
+ }
+ instances.trimToSize();
+ return instances;
+ }
+
+ public static Instances loadDisplay() throws IOException {
+ int[] selectionIndex = new int[] { -1 };
+ List instances = loadInstances(DisplayInstance.class, selectionIndex);
+ if(instances.isEmpty()) {
+ createFirstTimeInstance();
+ return loadDisplay();
+ }else if(selectionIndex[0] == -1) {
+ setSelectedInstance(instances.get(0));
+ selectionIndex[0] = 0;
+ }
+ return new Instances(Collections.unmodifiableList(instances), selectionIndex[0]);
+ }
+
+ public static List loadAllInstances() throws IOException {
+ return loadInstances(Instance.class, null);
+ }
+
+ private static File findNewInstanceRoot(String prefix) {
+ File instanceRoot;
+ do {
+ String proposedDirectoryName = UUID.randomUUID().toString();
+ if(prefix != null) {
+ proposedDirectoryName = prefix + "-" + proposedDirectoryName;
+ }
+ instanceRoot = new File(sInstancePath, proposedDirectoryName);
+ } while(instanceRoot.exists() && instanceRoot.isDirectory());
+ return instanceRoot;
+ }
+
+ /**
+ * Set the currently selected instance and save it in user preferences
+ * @param instance new selected instance
+ */
+ public static void setSelectedInstance(DisplayInstance instance) {
+ LauncherPreferences.DEFAULT_PREF.edit()
+ .putString(
+ LauncherPreferences.PREF_KEY_CURRENT_INSTANCE,
+ instance.mInstanceRoot.getName()
+ ).apply();
+ }
+
+ /**
+ * Remove the instance. This also removes its data storage folder.
+ * @param instance the Instance to remove
+ * @throws IOException in case of errors during directory removal
+ */
+ public static void removeInstance(Instance instance) throws IOException {
+ File instanceDirectory = instance.mInstanceRoot;
+ if(instanceDirectory == null) return;
+ org.apache.commons.io.FileUtils.deleteDirectory(instanceDirectory);
+ }
+
+ /**
+ * Create a new instance intended for first-time launcher users.
+ */
+ private static void createFirstTimeInstance() throws IOException {
+ internalCreateInstance((instance)-> {
+ instance.sharedData = true;
+ instance.versionId = "1.12.2";
+ }, null);
+ }
+
+ /**
+ * Create a new instance based on a default template.
+ * @return the new instance
+ */
+ public static Instance createDefaultInstance() throws IOException {
+ return createInstance((instance)-> {
+ instance.sharedData = true;
+ instance.versionId = Instance.VERSION_LATEST_RELEASE;
+ }, null);
+ }
+
+ /**
+ * Create an instance without attempting to load the instance list first. Only use this
+ * method during initialization.
+ */
+ private static Instance internalCreateInstance(InstanceSetter instanceSetter, String namePrefix) throws IOException{
+ File root = findNewInstanceRoot(namePrefix);
+ FileUtils.ensureDirectory(root);
+ Instance instance = new Instance();
+ instance.mInstanceRoot = root;
+ instanceSetter.setInstanceProperties(instance);
+ instance.write();
+ return instance;
+ }
+
+ /**
+ * Create a new instance with defaults set by user
+ * @param instanceSetter setter function called to set user parameters
+ * @param namePrefix a name prefix (for the user to easily distinguish installed instances)
+ * @return the created instance
+ * @throws IOException if directory creation/instance writing fails
+ */
+ public static Instance createInstance(InstanceSetter instanceSetter, String namePrefix) throws IOException {
+ return internalCreateInstance(instanceSetter, namePrefix);
+ }
+
+ /**
+ * Load the currently selected instance. Note that this method must not be used along with any code
+ * which uses getImmutableInstanceList()
+ * @return currently selected instance
+ */
+ public static Instance loadSelectedInstance() {
+ File selectedInstanceLocation = selectedInstanceLocation();
+ Instance instance = read(selectedInstanceLocation, Instance.class);
+ if(instance == null) return null;
+ instance.sanitize();
+ return instance;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java
new file mode 100644
index 0000000000..db584a46f4
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileBody.java
@@ -0,0 +1,6 @@
+package net.kdt.pojavlaunch.instances.profcompat;
+
+public class ProfileBody {
+ public String name;
+ public String lastVersionId;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java
new file mode 100644
index 0000000000..3925b3c844
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/ProfileWatcher.java
@@ -0,0 +1,34 @@
+package net.kdt.pojavlaunch.instances.profcompat;
+
+import android.content.res.AssetManager;
+
+import net.kdt.pojavlaunch.Tools;
+
+import java.io.File;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.util.Iterator;
+import java.util.Map;
+
+public class ProfileWatcher {
+ private static final File sLauncherProfiles = new File(Tools.DIR_GAME_NEW, "launcher_profiles.json");
+ public static String consumePendingVersion(AssetManager assetManager) throws IOException {
+ Profiles store;
+ try(FileReader fileReader = new FileReader(sLauncherProfiles)) {
+ store = Tools.GLOBAL_GSON.fromJson(fileReader, Profiles.class);
+ }
+ Map profiles = store.profiles;
+ String versionId = null;
+ for (Map.Entry entry : profiles.entrySet()) {
+ if ("(Default)".equals(entry.getKey())) continue;
+ versionId = entry.getValue().lastVersionId;
+ if(versionId != null) break;
+ }
+ installDefaultProfiles(assetManager);
+ return versionId;
+ }
+ public static void installDefaultProfiles(AssetManager assetManager) throws IOException {
+ Tools.copyAssetFile(assetManager, "launcher_profiles.json", sLauncherProfiles, true);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java
new file mode 100644
index 0000000000..e3bd99e897
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/instances/profcompat/Profiles.java
@@ -0,0 +1,8 @@
+package net.kdt.pojavlaunch.instances.profcompat;
+
+import java.util.Map;
+
+public class Profiles {
+ public Map profiles;
+ @SuppressWarnings("unused") public String selectedProfile;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java
new file mode 100644
index 0000000000..de60f55394
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/lifecycle/ActivityRunnable.java
@@ -0,0 +1,12 @@
+package net.kdt.pojavlaunch.lifecycle;
+
+import android.app.Activity;
+
+public interface ActivityRunnable {
+ /**
+ * 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);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java
index 2cced7b7ee..566f87a6c6 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/mirrors/DownloadMirror.java
@@ -76,14 +76,18 @@ public static void downloadFileMirrored(int downloadClass, String urlInput, File
* @param urlInput The original (Mojang) URL for the download
* @return the length of the file denoted by the URL in bytes, or -1 if not available
*/
- public static long getContentLengthMirrored(int downloadClass, String urlInput) throws IOException {
- long length = DownloadUtils.getContentLength(getMirrorMapping(downloadClass, urlInput));
- if(length < 1) {
- Log.w("DownloadMirror", "Unable to get content length from mirror");
- Log.i("DownloadMirror", "Falling back to default source");
- return DownloadUtils.getContentLength(urlInput);
- }else {
- return length;
+ public static long getContentLengthMirrored(int downloadClass, String urlInput){
+ try {
+ long length = DownloadUtils.getContentLength(getMirrorMapping(downloadClass, urlInput));
+ if (length < 1) {
+ Log.w("DownloadMirror", "Unable to get content length from mirror");
+ Log.i("DownloadMirror", "Falling back to default source");
+ return DownloadUtils.getContentLength(urlInput);
+ } else {
+ return length;
+ }
+ } catch (IOException ignored) { // If error happens, fallback to old file counter instead of size. This shouldn't really happen unless offline though.
+ return -1L;
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java
new file mode 100644
index 0000000000..649ffd6f6d
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ComparableVersionString.java
@@ -0,0 +1,72 @@
+package net.kdt.pojavlaunch.modloaders;
+
+public class ComparableVersionString implements Comparable {
+ private int major;
+ private int minor;
+ private int patch;
+ private final String original;
+ private final boolean isValid;
+
+ private ComparableVersionString(String str) {
+ this.original = str;
+ this.isValid = false;
+ }
+
+ public ComparableVersionString(String original, int major, int minor, int patch) {
+ this.major = major;
+ this.minor = minor;
+ this.patch = patch;
+ this.original = original;
+ this.isValid = true;
+ }
+
+ @Override
+ public int compareTo(ComparableVersionString str) {
+ if(!this.isValid) return str.getProper().compareTo(this.original);
+
+ if(this.major != str.major) return Integer.compare(this.major, str.major);
+ if(this.minor != str.minor) return Integer.compare(this.minor, str.minor);
+ if(this.patch != str.patch) return Integer.compare(this.patch, str.patch);
+ return 0;
+ }
+
+ public String getOriginal() {
+ return original;
+ }
+
+ /**
+ * @return the original but if the patch was .0 it will not include it, e.g.
+ * "1.20.0" -> "1.20"
+ */
+ public String getProper() {
+ if(!this.isValid) return original;
+
+ StringBuilder sb = new StringBuilder();
+ sb.append(major);
+ sb.append('.');
+ sb.append(minor);
+ if(patch != 0) {
+ sb.append('.');
+ sb.append(patch);
+ }
+ return sb.toString();
+ }
+
+ public boolean isValid() {
+ return isValid;
+ }
+
+ public static ComparableVersionString parse(String str) {
+ String[] split = str.split("\\.");
+ if (split.length < 2) return new ComparableVersionString(str);
+
+ try {
+ int major = Integer.parseInt(split[0]);
+ int minor = Integer.parseInt(split[1]);
+ int patch = (split.length >= 3) ? Integer.parseInt(split[2]) : 0;
+ return new ComparableVersionString(str, major, minor, patch);
+ } catch (NumberFormatException e) {
+ return new ComparableVersionString(str);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java
new file mode 100644
index 0000000000..8367daf910
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeUtils.java
@@ -0,0 +1,176 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import android.util.Log;
+
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.instances.InstanceInstaller;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import org.xml.sax.InputSource;
+import org.xml.sax.SAXException;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.StringReader;
+import java.util.List;
+
+import javax.xml.parsers.ParserConfigurationException;
+import javax.xml.parsers.SAXParser;
+import javax.xml.parsers.SAXParserFactory;
+
+public abstract class ForgelikeUtils {
+ public static final ForgelikeUtils FORGE_UTILS = new ForgeUtils();
+ public static final ForgelikeUtils NEOFORGE_UTILS = new NeoforgeUtils();
+
+ private final String mName;
+ private final String mCachePrefix;
+ private final String mVersionResolver;
+ private final String mIconName;
+ private final String mMetadataUrl;
+ private final String mInstallerUrl;
+ private final boolean mVersionOrderInversed;
+
+ private ForgelikeUtils(String name, String cachePrefix, String iconName, String versionResolver, String metadataUrl, String installerUrl, boolean versionOrderInversed) {
+ this.mName = name;
+ this.mCachePrefix = cachePrefix;
+ this.mIconName = iconName;
+ this.mVersionResolver = versionResolver;
+ this.mMetadataUrl = metadataUrl;
+ this.mInstallerUrl = installerUrl;
+ this.mVersionOrderInversed = versionOrderInversed;
+ }
+
+ public List downloadVersions() throws IOException {
+ SAXParser saxParser;
+ try {
+ SAXParserFactory parserFactory = SAXParserFactory.newInstance();
+ saxParser = parserFactory.newSAXParser();
+ } catch (SAXException | ParserConfigurationException e) {
+ e.printStackTrace();
+ // if we cant make a parser we might as well not even try to parse anything
+ return null;
+ }
+ try {
+ //of_test();
+ return DownloadUtils.downloadStringCached(mMetadataUrl, mCachePrefix + "_versions", input -> {
+ try {
+ ForgelikeVersionListHandler handler = new ForgelikeVersionListHandler();
+ saxParser.parse(new InputSource(new StringReader(input)), handler);
+ return handler.getVersions();
+ // IOException is present here StringReader throws it only if the parser called close()
+ // sooner than needed, which is a parser issue and not an I/O one
+ } catch (SAXException | IOException e) {
+ throw new DownloadUtils.ParseException(e);
+ }
+ });
+ } catch (DownloadUtils.ParseException e) {
+ e.printStackTrace();
+ return null;
+ }
+ }
+
+ public String getInstallerUrl(String version) {
+ return String.format(mInstallerUrl, version);
+ }
+
+ public InstanceInstaller createInstaller(String gameVersion, String modLoaderVersion) throws IOException {
+ List versions = downloadVersions();
+ if (versions == null) return null;
+ String versionStart = String.format(mVersionResolver, gameVersion, modLoaderVersion);
+ for (String versionName : versions) {
+ if (!versionName.startsWith(versionStart)) continue;
+ return createInstaller(versionName);
+ }
+ return null;
+ }
+
+ public InstanceInstaller createInstaller(String fullVersion) throws IOException {
+ String downloadUrl = getInstallerUrl(fullVersion);
+ String hash = DownloadUtils.downloadString(downloadUrl + ".sha1");
+ File installerLocation = new File(Tools.DIR_CACHE, mCachePrefix + "-installer-" + fullVersion + ".jar");
+ InstanceInstaller instanceInstaller = new InstanceInstaller();
+ instanceInstaller.commandLineArgs = List.of("-Duser.language=en", "-Duser.country=US", "-javaagent:"+Tools.DIR_DATA+"/forge_installer/forge_installer.jar");
+ instanceInstaller.installerJar = installerLocation.getAbsolutePath();
+ instanceInstaller.installerSha1 = hash;
+ instanceInstaller.installerDownloadUrl = downloadUrl;
+ return instanceInstaller;
+ }
+
+ public String getName() {
+ return mName;
+ }
+
+ public String getIconName() {
+ return mIconName;
+ }
+
+ public abstract String processVersionString(String version);
+
+ public abstract boolean shouldSkipVersion(String version);
+
+ public boolean isVersionOrderInversed() {
+ return mVersionOrderInversed;
+ }
+
+ private static String getMcVersionForNeoVersion(String neoVersion) {
+ // I feel like it's necessary to explain the NeoForge versioning format
+ // basically, what it does is it trims the major version from minecrafts version
+ // e.g.: 1.20.1 -> 20.1, and then appends its own "patch" version to that
+ // e.g.: 20.1 -> 20.1.8, which means the version string includes both, the minecraft
+ // and the loader version at once
+ try {
+ int firstIndex = neoVersion.indexOf('.');
+ int secondIndex = neoVersion.indexOf('.', firstIndex + 1);
+ if (firstIndex == -1 || secondIndex == -1) {
+ Log.e("NeoforgeUtils", "Failed to parse neoforge version: " + neoVersion + "; not enough '.' found");
+ }
+ return "1." + neoVersion.substring(0, secondIndex);
+ } catch (StringIndexOutOfBoundsException e) {
+ Log.e("NeoforgeUtils", "Failed to parse neoforge version: " + neoVersion, e);
+ return neoVersion;
+ }
+ }
+
+ private static class ForgeUtils extends ForgelikeUtils {
+ public ForgeUtils() {
+ super(
+ "Forge", "forge", "forge", "%1$s-%2$s",
+ "https://maven.minecraftforge.net/net/minecraftforge/forge/maven-metadata.xml",
+ "https://maven.minecraftforge.net/net/minecraftforge/forge/%1$s/forge-%1$s-installer.jar",
+ false
+ );
+ }
+
+ @Override
+ public String processVersionString(String version) {
+ int dashIndex = version.indexOf("-");
+ return version.substring(0, dashIndex);
+ }
+
+ @Override
+ public boolean shouldSkipVersion(String version) {
+ return false;
+ }
+ }
+
+ private static class NeoforgeUtils extends ForgelikeUtils {
+ public NeoforgeUtils() {
+ super(
+ "NeoForge", "neoforge", "neoforge", "%2$s",
+ "https://maven.neoforged.net/releases/net/neoforged/neoforge/maven-metadata.xml",
+ "https://maven.neoforged.net/releases/net/neoforged/neoforge/%1$s/neoforge-%1$s-installer.jar",
+ true
+ );
+ }
+
+ @Override
+ public String processVersionString(String version) {
+ return ComparableVersionString.parse(getMcVersionForNeoVersion(version)).getProper();
+ }
+
+ @Override
+ public boolean shouldSkipVersion(String version) {
+ return version.startsWith("0");
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java
new file mode 100644
index 0000000000..e0eaee27c9
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListAdapter.java
@@ -0,0 +1,118 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListAdapter;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ForgelikeVersionListAdapter extends BaseExpandableListAdapter implements ExpandableListAdapter {
+ private final List mGameVersions;
+ private final List> mLoaderVersions;
+ private final LayoutInflater mLayoutInflater;
+
+ public ForgelikeVersionListAdapter(List forgeVersions, LayoutInflater layoutInflater, ForgelikeUtils utils) {
+ this.mLayoutInflater = layoutInflater;
+ mGameVersions = new ArrayList<>();
+ mLoaderVersions = new ArrayList<>();
+ for(String version : forgeVersions) {
+ if(utils.shouldSkipVersion(version)) continue;
+ String gameVersion = utils.processVersionString(version);
+ List versionList;
+ int gameVersionIndex = mGameVersions.indexOf(gameVersion);
+ if(gameVersionIndex != -1) {
+ versionList = mLoaderVersions.get(gameVersionIndex);
+ } else {
+ versionList = new ArrayList<>();
+ mGameVersions.add(gameVersion);
+ mLoaderVersions.add(versionList);
+ }
+ versionList.add(version);
+ }
+ if(utils.isVersionOrderInversed()) {
+ for (List versionList : mLoaderVersions) {
+ reverseList(versionList);
+ }
+ reverseList(mLoaderVersions);
+ reverseList(mGameVersions);
+ }
+ }
+
+ @Override
+ public int getGroupCount() {
+ return mGameVersions.size();
+ }
+
+ @Override
+ public int getChildrenCount(int i) {
+ return mLoaderVersions.get(i).size();
+ }
+
+ @Override
+ public Object getGroup(int i) {
+ return getGameVersion(i);
+ }
+
+ @Override
+ public Object getChild(int i, int i1) {
+ return getForgeVersion(i, i1);
+ }
+
+ @Override
+ public long getGroupId(int i) {
+ return i;
+ }
+
+ @Override
+ public long getChildId(int i, int i1) {
+ return i1;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getGroupView(int i, boolean b, View convertView, ViewGroup viewGroup) {
+ if(convertView == null)
+ convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false);
+
+ ((TextView) convertView).setText(getGameVersion(i));
+
+ return convertView;
+ }
+
+ @Override
+ public View getChildView(int i, int i1, boolean b, View convertView, ViewGroup viewGroup) {
+ if(convertView == null)
+ convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false);
+ ((TextView) convertView).setText(getForgeVersion(i, i1));
+ return convertView;
+ }
+
+ private String getGameVersion(int i) {
+ return mGameVersions.get(i);
+ }
+
+ private String getForgeVersion(int i, int i1){
+ return mLoaderVersions.get(i).get(i1);
+ }
+
+ @Override
+ public boolean isChildSelectable(int i, int i1) {
+ return true;
+ }
+
+ private static void reverseList(List list) {
+ for (int i = 0, j = list.size() - 1; i < j; i++, j--) {
+ T temp = list.get(i);
+ list.set(i, list.get(j));
+ list.set(j, temp);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java
new file mode 100644
index 0000000000..29cb6c3130
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/ForgelikeVersionListHandler.java
@@ -0,0 +1,39 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class ForgelikeVersionListHandler extends DefaultHandler {
+ private List mForgeVersions;
+ private StringBuilder mCurrentVersion = null;
+ @Override
+ public void startDocument() throws SAXException {
+ mForgeVersions = new ArrayList<>();
+ }
+
+ @Override
+ public void characters(char[] ch, int start, int length) throws SAXException {
+ if(mCurrentVersion != null) mCurrentVersion.append(ch, start, length);
+ }
+
+ @Override
+ public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
+ if(qName.equals("version")) mCurrentVersion = new StringBuilder();
+ }
+
+ @Override
+ public void endElement(String uri, String localName, String qName) throws SAXException {
+ if (qName.equals("version")) {
+ String version = mCurrentVersion.toString();
+ mForgeVersions.add(version);
+ mCurrentVersion = null;
+ }
+ }
+ public List getVersions() {
+ return mForgeVersions;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java
new file mode 100644
index 0000000000..68a81977e7
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/InstalledModAdapter.java
@@ -0,0 +1,367 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import android.app.AlertDialog;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageButton;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.appcompat.widget.SwitchCompat;
+import androidx.recyclerview.widget.RecyclerView;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
+
+import net.kdt.pojavlaunch.PojavApplication;
+import net.kdt.pojavlaunch.R;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.List;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+public class InstalledModAdapter extends RecyclerView.Adapter {
+
+ private static final String TAG = "ModAdapter";
+
+ public interface EmptyStateListener {
+ void onEmptyStateChanged(boolean isEmpty);
+ }
+
+ private final List mMods = new ArrayList<>();
+ private final EmptyStateListener mEmptyListener;
+ private final Handler mMainHandler = new Handler(Looper.getMainLooper());
+
+ public InstalledModAdapter(File modsDir, EmptyStateListener listener) {
+ mEmptyListener = listener;
+ if (modsDir != null && modsDir.isDirectory()) {
+ File[] files = modsDir.listFiles(f -> f.isFile() &&
+ (f.getName().endsWith(".jar") || f.getName().endsWith(".jar.disabled")));
+ if (files != null) {
+ Arrays.sort(files, (a, b) -> a.getName().compareToIgnoreCase(b.getName()));
+ for (File f : files) mMods.add(new ModEntry(f));
+ }
+ }
+ notifyEmptyState();
+ }
+
+ @NonNull
+ @Override
+ public ModViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
+ View v = LayoutInflater.from(parent.getContext())
+ .inflate(R.layout.item_installed_mod, parent, false);
+ return new ModViewHolder(v);
+ }
+
+ @Override
+ public void onBindViewHolder(@NonNull ModViewHolder holder, int position) {
+ holder.bind(mMods.get(position));
+ }
+
+ @Override
+ public void onViewRecycled(@NonNull ModViewHolder holder) {
+ // Clear the tag so any in-flight load doesn't update this recycled view
+ holder.icon.setTag(null);
+ holder.icon.setImageResource(R.drawable.ic_add_modded);
+ super.onViewRecycled(holder);
+ }
+
+ @Override
+ public int getItemCount() { return mMods.size(); }
+
+ private void notifyEmptyState() {
+ if (mEmptyListener != null) mEmptyListener.onEmptyStateChanged(mMods.isEmpty());
+ }
+
+ // ── ViewHolder ────────────────────────────────────────────────────────
+
+ class ModViewHolder extends RecyclerView.ViewHolder {
+ final ImageView icon;
+ final TextView name, version;
+ final SwitchCompat toggle;
+ final ImageButton delete;
+
+ ModViewHolder(@NonNull View itemView) {
+ super(itemView);
+ icon = itemView.findViewById(R.id.installed_mod_icon);
+ name = itemView.findViewById(R.id.installed_mod_name);
+ version = itemView.findViewById(R.id.installed_mod_version);
+ toggle = itemView.findViewById(R.id.installed_mod_toggle);
+ delete = itemView.findViewById(R.id.installed_mod_delete);
+ }
+
+ void bind(ModEntry entry) {
+ name.setText(entry.displayName());
+ version.setText(entry.file.getName());
+
+ // Tag the ImageView with the file path so we can verify it hasn't been recycled
+ icon.setTag(entry.file.getAbsolutePath());
+ icon.setImageResource(R.drawable.ic_add_modded);
+
+ final String expectedTag = entry.file.getAbsolutePath();
+ final WeakReference iconRef = new WeakReference<>(icon);
+ final File jarFile = entry.file;
+
+ PojavApplication.sExecutorService.execute(() -> {
+ Bitmap bmp = extractModIcon(jarFile);
+ if (bmp == null) return;
+ mMainHandler.post(() -> {
+ ImageView iv = iconRef.get();
+ // Only update if the view still belongs to the same mod
+ if (iv != null && expectedTag.equals(iv.getTag())) {
+ iv.setImageBitmap(bmp);
+ }
+ });
+ });
+
+ toggle.setOnCheckedChangeListener(null);
+ toggle.setChecked(entry.enabled);
+ toggle.setOnCheckedChangeListener((btn, checked) -> entry.setEnabled(checked));
+
+ delete.setOnClickListener(v -> {
+ Context ctx = v.getContext();
+ new AlertDialog.Builder(ctx)
+ .setTitle(ctx.getString(R.string.manage_mods_delete_confirm, entry.displayName()))
+ .setNegativeButton(android.R.string.cancel, null)
+ .setPositiveButton(android.R.string.ok, (d, i) -> {
+ entry.file.delete();
+ int p = getBindingAdapterPosition();
+ if (p != RecyclerView.NO_POSITION) {
+ mMods.remove(p);
+ notifyItemRemoved(p);
+ notifyEmptyState();
+ }
+ })
+ .show();
+ });
+ }
+ }
+
+ // ── ModEntry ──────────────────────────────────────────────────────────
+
+ static class ModEntry {
+ File file;
+ boolean enabled;
+
+ ModEntry(File f) {
+ this.file = f;
+ this.enabled = !f.getName().endsWith(".disabled");
+ }
+
+ String displayName() {
+ String n = file.getName();
+ if (n.endsWith(".jar.disabled")) n = n.substring(0, n.length() - 13);
+ else if (n.endsWith(".jar")) n = n.substring(0, n.length() - 4);
+ return n;
+ }
+
+ void setEnabled(boolean enable) {
+ if (enable == this.enabled) return;
+ File target = enable
+ ? new File(file.getParent(), file.getName().replace(".jar.disabled", ".jar"))
+ : new File(file.getParent(), file.getName() + ".disabled");
+ if (file.renameTo(target)) {
+ file = target;
+ this.enabled = enable;
+ }
+ }
+ }
+
+ // ── Icon extraction ───────────────────────────────────────────────────
+
+ @Nullable
+ private static Bitmap extractModIcon(File jarFile) {
+ try (ZipFile zip = new ZipFile(jarFile)) {
+ String iconPath = resolveIconPath(zip);
+ Log.d(TAG, jarFile.getName() + " → icon path: " + iconPath);
+
+ if (iconPath != null) {
+ Bitmap bmp = loadEntryAsBitmap(zip, iconPath);
+ if (bmp != null) return bmp;
+ Log.w(TAG, "Icon path resolved but bitmap failed: " + iconPath);
+ }
+
+ // Fallback scan — some old mods don't declare icon in metadata
+ for (String fallback : new String[]{"pack.png", "icon.png", "logo.png"}) {
+ Bitmap bmp = loadEntryAsBitmap(zip, fallback);
+ if (bmp != null) return bmp;
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "Failed to open JAR: " + jarFile.getName() + " — " + e.getMessage());
+ }
+ return null;
+ }
+
+ @Nullable
+ private static String resolveIconPath(ZipFile zip) {
+ // 1. Fabric — fabric.mod.json → "icon" (string OR {"64":"path"} object)
+ String content = readEntry(zip, "fabric.mod.json");
+ if (content != null) {
+ try {
+ JsonObject obj = JsonParser.parseString(content).getAsJsonObject();
+ if (obj.has("icon")) {
+ JsonElement iconEl = obj.get("icon");
+ if (iconEl.isJsonPrimitive()) {
+ return iconEl.getAsString();
+ } else if (iconEl.isJsonObject()) {
+ // Size map e.g. {"64": "path64.png", "128": "path128.png"}
+ // Pick the largest available
+ JsonObject sizeMap = iconEl.getAsJsonObject();
+ String best = null;
+ int bestSize = 0;
+ for (String key : sizeMap.keySet()) {
+ try {
+ int sz = Integer.parseInt(key);
+ if (sz > bestSize) {
+ bestSize = sz;
+ best = sizeMap.get(key).getAsString();
+ }
+ } catch (NumberFormatException ignored) {
+ best = sizeMap.get(key).getAsString();
+ }
+ }
+ if (best != null) return best;
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "fabric.mod.json parse error: " + e.getMessage());
+ }
+ }
+
+ // 2. Quilt — quilt.mod.json → quilt_loader.metadata.icon
+ content = readEntry(zip, "quilt.mod.json");
+ if (content != null) {
+ try {
+ JsonObject root = JsonParser.parseString(content).getAsJsonObject();
+ JsonObject ql = root.has("quilt_loader") ?
+ root.getAsJsonObject("quilt_loader") : null;
+ if (ql != null && ql.has("metadata")) {
+ JsonObject meta = ql.getAsJsonObject("metadata");
+ if (meta.has("icon") && meta.get("icon").isJsonPrimitive())
+ return meta.get("icon").getAsString();
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "quilt.mod.json parse error: " + e.getMessage());
+ }
+ }
+
+ // 3. Forge legacy — mcmod.info → logoFile
+ content = readEntry(zip, "mcmod.info");
+ if (content != null) {
+ try {
+ // mcmod.info is a JSON array
+ JsonArray arr = JsonParser.parseString(content).getAsJsonArray();
+ if (arr.size() > 0 && arr.get(0).isJsonObject()) {
+ JsonObject mod = arr.get(0).getAsJsonObject();
+ if (mod.has("logoFile")) {
+ String logo = mod.get("logoFile").getAsString();
+ if (!logo.isEmpty()) return logo;
+ }
+ }
+ } catch (Exception e) {
+ Log.w(TAG, "mcmod.info parse error: " + e.getMessage());
+ }
+ }
+
+ // 4. Forge/NeoForge — TOML — logoFile = "path"
+ for (String toml : new String[]{"META-INF/neoforge.mods.toml", "META-INF/mods.toml"}) {
+ content = readEntry(zip, toml);
+ if (content != null) {
+ String logo = tomlStringField(content, "logoFile");
+ if (logo != null && !logo.isEmpty()) return logo;
+ }
+ }
+
+ return null;
+ }
+
+ // ── Low-level helpers ─────────────────────────────────────────────────
+
+ /**
+ * Load a ZipEntry as a Bitmap.
+ * IMPORTANT: BitmapFactory.decodeStream needs mark/reset support.
+ * ZipInputStream doesn't support it, so we buffer all bytes first.
+ */
+ @Nullable
+ private static Bitmap loadEntryAsBitmap(ZipFile zip, String entryPath) {
+ // ZipFile.getEntry is case-sensitive — try exact then case-insensitive scan
+ ZipEntry entry = zip.getEntry(entryPath);
+ if (entry == null) {
+ // Case-insensitive fallback
+ String lower = entryPath.toLowerCase();
+ Enumeration extends ZipEntry> entries = zip.entries();
+ while (entries.hasMoreElements()) {
+ ZipEntry e = entries.nextElement();
+ if (e.getName().toLowerCase().equals(lower)) {
+ entry = e;
+ break;
+ }
+ }
+ }
+ if (entry == null) return null;
+
+ try (InputStream is = zip.getInputStream(entry)) {
+ // Buffer into byte array — BitmapFactory needs mark/reset
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int read;
+ while ((read = is.read(buf)) != -1) baos.write(buf, 0, read);
+ byte[] bytes = baos.toByteArray();
+ return BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
+ } catch (Exception e) {
+ Log.w(TAG, "loadEntryAsBitmap failed for " + entryPath + ": " + e.getMessage());
+ return null;
+ }
+ }
+
+ @Nullable
+ private static String readEntry(ZipFile zip, String entryPath) {
+ ZipEntry entry = zip.getEntry(entryPath);
+ if (entry == null) return null;
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(zip.getInputStream(entry), "UTF-8"))) {
+ StringBuilder sb = new StringBuilder();
+ String line;
+ while ((line = reader.readLine()) != null) sb.append(line).append('\n');
+ return sb.toString();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ @Nullable
+ private static String tomlStringField(String toml, String field) {
+ for (String line : toml.split("\n")) {
+ line = line.trim();
+ if (line.startsWith(field + " ") || line.startsWith(field + "=")) {
+ int eq = line.indexOf('=');
+ if (eq < 0) continue;
+ String val = line.substring(eq + 1).trim();
+ if (val.startsWith("\"") && val.endsWith("\""))
+ val = val.substring(1, val.length() - 1);
+ if (!val.isEmpty()) return val;
+ }
+ }
+ return null;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java
new file mode 100644
index 0000000000..86021ce927
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java
@@ -0,0 +1,84 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import androidx.annotation.NonNull;
+
+import com.kdt.mcgui.ProgressLayout;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.fragments.NeoForgeInstallFragment;
+import net.kdt.pojavlaunch.progresskeeper.ProgressKeeper;
+import net.kdt.pojavlaunch.utils.DownloadUtils;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.List;
+
+public class NeoForgeDownloadTask implements Runnable, Tools.DownloaderFeedback {
+ private final String mDownloadUrl;
+ private final String mLoaderVersion;
+
+ private final ModloaderDownloadListener mListener;
+
+ public NeoForgeDownloadTask(ModloaderDownloadListener listener, @NonNull String loaderVersion) {
+ this.mListener = listener;
+ this.mDownloadUrl = String.format(NEOFORGE_INSTALLER_URL, loaderVersion);
+ this.mLoaderVersion = loaderVersion;
+ }
+
+ private static final String NEOFORGE_INSTALLER_URL = "https://maven.neoforged.net/releases/net/neoforged/neoforge/%1$s/neoforge-%1$s-installer.jar";
+
+ @Override
+ public void run() {
+ if(determineDownloadUrl()) {
+ downloadNeoForge();
+ }
+ 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, mLoaderVersion);
+ }
+
+ private void downloadNeoForge() {
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_progress, mLoaderVersion);
+ try {
+ File destinationFile = new File(Tools.DIR_CACHE, "neoforge-installer.jar");
+ byte[] buffer = new byte[8192];
+ DownloadUtils.downloadFileMonitored(mDownloadUrl, destinationFile, buffer, this);
+ mListener.onDownloadFinished(destinationFile);
+ }catch (FileNotFoundException e) {
+ mListener.onDataNotAvailable();
+ } catch (IOException e) {
+ mListener.onDownloadError(e);
+ }
+ }
+
+ public boolean determineDownloadUrl() {
+ ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.neoforge_dl_searching);
+ try {
+ if(!findVersion()) {
+ mListener.onDataNotAvailable();
+ return false;
+ }
+ }catch (IOException e) {
+ mListener.onDownloadError(e);
+ return false;
+ }
+ return true;
+ }
+
+ public boolean findVersion() throws IOException {
+ List neoforgeVersions = NeoForgeInstallFragment.downloadNeoForgeVersions();
+ if(neoforgeVersions == null) return false;
+ for(String versionName : neoforgeVersions) {
+ if(!versionName.startsWith(mLoaderVersion)) continue;
+ return true;
+ }
+ return false;
+ }
+
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java
new file mode 100644
index 0000000000..df7db75b1e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java
@@ -0,0 +1,118 @@
+package net.kdt.pojavlaunch.modloaders;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.ExpandableListAdapter;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class NeoForgeVersionListAdapter extends BaseExpandableListAdapter implements ExpandableListAdapter {
+ private final List mGameVersions;
+ private final List> mNeoForgeVersions;
+ private final LayoutInflater mLayoutInflater;
+
+
+ public NeoForgeVersionListAdapter(List neoforgeVersions, LayoutInflater layoutInflater) {
+ this.mLayoutInflater = layoutInflater;
+ mGameVersions = new ArrayList<>();
+ mNeoForgeVersions = new ArrayList<>();
+ for(String version : neoforgeVersions) {
+ String[] parts = version.split("\\.");
+ String gameVersion;
+ try {
+ if (Integer.parseInt(parts[1]) < 25) { // Actual logic for normal mcvers
+ gameVersion = "1." + parts[0] + "." + parts[1];
+ } else gameVersion = parts[0] + "." + parts[1];
+ } catch (NumberFormatException ignored) {
+ // Handling for april fools version
+ gameVersion = parts[0] + "." + parts[1];
+ }
+ List versionList;
+ int gameVersionIndex = mGameVersions.indexOf(gameVersion);
+ if(gameVersionIndex != -1) versionList = mNeoForgeVersions.get(gameVersionIndex);
+ else {
+ versionList = new ArrayList<>();
+ mGameVersions.add(gameVersion);
+ mNeoForgeVersions.add(versionList);
+ }
+ versionList.add(version);
+ }
+ // Make it latest to oldest, top to down.
+ Collections.reverse(mGameVersions);
+ Collections.reverse(mNeoForgeVersions);
+ for (List mNeoForgeVersion : mNeoForgeVersions){
+ Collections.reverse(mNeoForgeVersion);
+ }
+ }
+ @Override
+ public int getGroupCount() {
+ return mGameVersions.size();
+ }
+
+ @Override
+ public int getChildrenCount(int i) {
+ return mNeoForgeVersions.get(i).size();
+ }
+
+ @Override
+ public Object getGroup(int i) {
+ return getGameVersion(i);
+ }
+
+ @Override
+ public Object getChild(int i, int i1) {
+ return getNeoForgeVersion(i, i1);
+ }
+
+ @Override
+ public long getGroupId(int i) {
+ return i;
+ }
+
+ @Override
+ public long getChildId(int i, int i1) {
+ return i1;
+ }
+
+ @Override
+ public boolean hasStableIds() {
+ return true;
+ }
+
+ @Override
+ public View getGroupView(int i, boolean b, View convertView, ViewGroup viewGroup) {
+ if(convertView == null)
+ convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false);
+
+ ((TextView) convertView).setText(getGameVersion(i));
+
+ return convertView;
+ }
+
+ @Override
+ public View getChildView(int i, int i1, boolean b, View convertView, ViewGroup viewGroup) {
+ if(convertView == null)
+ convertView = mLayoutInflater.inflate(android.R.layout.simple_expandable_list_item_1, viewGroup, false);
+ ((TextView) convertView).setText(getNeoForgeVersion(i, i1));
+ return convertView;
+ }
+
+ private String getGameVersion(int i) {
+ return mGameVersions.get(i);
+ }
+
+ private String getNeoForgeVersion(int i, int i1){
+ return mNeoForgeVersions.get(i).get(i1);
+ }
+
+ @Override
+ public boolean isChildSelectable(int i, int i1) {
+ return 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 91e396ac3f..8cba39414a 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
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch.modloaders;
+import android.app.Activity;
+
import com.kdt.mcgui.ProgressLayout;
import net.kdt.pojavlaunch.JMinecraftVersionList;
@@ -22,11 +24,13 @@ public class OptiFineDownloadTask implements Runnable, Tools.DownloaderFeedback,
private final ModloaderDownloadListener mListener;
private final Object mMinecraftDownloadLock = new Object();
private Throwable mDownloaderThrowable;
+ private final Activity activity;
- public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, ModloaderDownloadListener mListener) {
+ public OptiFineDownloadTask(OptiFineUtils.OptiFineVersion mOptiFineVersion, ModloaderDownloadListener mListener, Activity activity) {
this.mOptiFineVersion = mOptiFineVersion;
this.mDestinationFile = new File(Tools.DIR_CACHE, "optifine-installer.jar");
this.mListener = mListener;
+ this.activity = activity;
}
@Override
@@ -89,7 +93,7 @@ public boolean downloadMinecraft(String minecraftVersion) {
if(minecraftJsonVersion == null) return false;
try {
synchronized (mMinecraftDownloadLock) {
- new MinecraftDownloader().start(null, minecraftJsonVersion, minecraftVersion, this);
+ new MinecraftDownloader().start(activity, minecraftJsonVersion, minecraftVersion, this);
mMinecraftDownloadLock.wait();
}
}catch (InterruptedException e) {
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
index cbd43bd304..7383e2c2b3 100644
--- 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
@@ -1,21 +1,32 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api;
+import android.app.Activity;
+import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
import net.kdt.pojavlaunch.PojavApplication;
+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.SearchFilters;
import net.kdt.pojavlaunch.modloaders.modpacks.models.SearchResult;
+import org.jdom2.IllegalDataException;
+
+import java.io.File;
import java.io.IOException;
+import java.io.InputStream;
+import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Future;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
/**
* Group all apis under the same umbrella, as another layer of abstraction
@@ -112,6 +123,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE
return getModpackApi(modDetail.apiSource).installMod(modDetail, selectedVersion);
}
+ @Override
+ public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException {
+ return getModpackApi(activity, zipUri).importModpack(activity, zipUri);
+ }
+
private @NonNull ModpackApi getModpackApi(int apiSource) {
switch (apiSource) {
case Constants.SOURCE_MODRINTH:
@@ -123,6 +139,31 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE
}
}
+ private @NonNull ModpackApi getModpackApi(Activity activity, Uri zipUri){
+ String modrinthPackInfoFileName = "modrinth.index.json";
+ String curseforgePackInfoFileName = "manifest.json";
+ InputStream inputStream = null;
+ try {
+ inputStream = activity.getContentResolver().openInputStream(zipUri);
+ ZipInputStream zipInputStream = new ZipInputStream(inputStream);
+ ZipEntry zipEntry;
+ boolean isModrinth;
+ boolean isCurseforge;
+ while ((zipEntry = zipInputStream.getNextEntry()) != null) {
+ isModrinth = zipEntry.getName().equals(modrinthPackInfoFileName);
+ isCurseforge = zipEntry.getName().equals(curseforgePackInfoFileName);
+ if(isModrinth) {
+ return mModrinthApi;
+ } else if (isCurseforge) {
+ return mCurseforgeApi;
+ }
+ }
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ throw new IllegalArgumentException("Zip provided does not contain a manifest file");
+ }
+
/** Fuse the arrays in a way that's fair for every endpoint */
private ModItem[] buildFusedResponse(List modMatrix){
int totalSize = 0;
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
index 940e4452a4..908d987eb9 100644
--- 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
@@ -1,5 +1,7 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api;
+import android.app.Activity;
+import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -25,6 +27,7 @@
import java.io.File;
import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.regex.Pattern;
@@ -61,6 +64,17 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous
params.put("sortOrder", "desc");
if(searchFilters.mcVersion != null && !searchFilters.mcVersion.isEmpty())
params.put("gameVersion", searchFilters.mcVersion);
+ if(searchFilters.modLoader != null && !searchFilters.modLoader.isEmpty()) {
+ // CF modLoaderType: 1=Forge, 4=Fabric, 5=Quilt, 6=NeoForge
+ int modLoaderType = 0;
+ switch(searchFilters.modLoader.toLowerCase()) {
+ case "forge": modLoaderType = 1; break;
+ case "fabric": modLoaderType = 4; break;
+ case "quilt": modLoaderType = 5; break;
+ case "neoforge": modLoaderType = 6; break;
+ }
+ if(modLoaderType != 0) params.put("modLoaderType", modLoaderType);
+ }
if(previousPageResult != null)
params.put("index", curseforgeSearchResult.previousOffset);
@@ -75,16 +89,33 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous
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()) {
+ boolean restricted = !allowModDistribution.isJsonNull() && !allowModDistribution.getAsBoolean();
+ // For modpacks, skip restricted entries entirely (same as before)
+ // For individual mods, keep them so we can show the CF website dialog
+ if (restricted && searchFilters.isModpack) {
Log.i("CurseforgeApi", "Skipping modpack "+dataElement.get("name").getAsString() + " because curseforge sucks");
continue;
}
+ JsonObject logo = dataElement.getAsJsonObject("logo");
+ String thumbnailUrl = (logo != null && logo.has("thumbnailUrl") && !logo.get("thumbnailUrl").isJsonNull())
+ ? logo.get("thumbnailUrl").getAsString() : "";
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());
+ thumbnailUrl);
+ modItem.isRestricted = restricted;
+ // Capture the mod page URL from CF API for use in restriction dialog
+ JsonObject links = dataElement.getAsJsonObject("links");
+ if (links != null && links.has("websiteUrl") && !links.get("websiteUrl").isJsonNull()) {
+ modItem.websiteUrl = links.get("websiteUrl").getAsString();
+ } else {
+ // Fallback using slug if available, otherwise numeric id
+ String slug = dataElement.has("slug") && !dataElement.get("slug").isJsonNull()
+ ? dataElement.get("slug").getAsString() : modItem.id;
+ modItem.websiteUrl = "https://www.curseforge.com/minecraft/mc-mods/" + slug;
+ }
modItemList.add(modItem);
}
if(curseforgeSearchResult == null) curseforgeSearchResult = new CurseforgeSearchResult();
@@ -97,6 +128,18 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous
@Override
public ModDetail getModDetails(ModItem item) {
+ return getModDetails(item, null);
+ }
+
+ public ModDetail getModDetails(ModItem item, String filterMcVersion) {
+ // Short-circuit for restricted mods — no point fetching versions
+ if (item.isRestricted) {
+ return new ModDetail(item,
+ new String[]{"Blocked by the author!"},
+ new String[]{null},
+ new String[]{null},
+ new String[]{null});
+ }
ArrayList allModDetails = new ArrayList<>();
int index = 0;
while(index != CURSEFORGE_PAGINATION_END_REACHED &&
@@ -104,7 +147,43 @@ public ModDetail getModDetails(ModItem item) {
index = getPaginatedDetails(allModDetails, index, item.id);
}
if(index == CURSEFORGE_PAGINATION_ERROR) return null;
+
+ // Filter by MC version if specified
+ if (filterMcVersion != null && !filterMcVersion.isEmpty()) {
+ ArrayList filtered = new ArrayList<>();
+ for (JsonObject v : allModDetails) {
+ JsonArray gameVersions = v.getAsJsonArray("gameVersions");
+ for (JsonElement el : gameVersions) {
+ if (filterMcVersion.equals(el.getAsString())) {
+ filtered.add(v);
+ break;
+ }
+ }
+ }
+ allModDetails = filtered;
+ }
+
int length = allModDetails.size();
+
+ // Check if ALL versions have null downloadUrl (fully restricted mod)
+ boolean allRestricted = length > 0;
+ for (int i = 0; i < length; i++) {
+ JsonElement url = allModDetails.get(i).get("downloadUrl");
+ if (url != null && !url.isJsonNull()) {
+ allRestricted = false;
+ break;
+ }
+ }
+ if (allRestricted || length == 0) {
+ // All versions restricted - return a ModDetail with one entry that signals this.
+ // The version name shown in spinner makes it clear, tapping Install shows CF dialog.
+ return new ModDetail(item,
+ new String[]{"Blocked by the author! By clicking the install button it will open the CurseForge page."},
+ new String[]{null},
+ new String[]{null},
+ new String[]{null});
+ }
+
String[] versionNames = new String[length];
String[] mcVersionNames = new String[length];
String[] versionUrls = new String[length];
@@ -114,7 +193,11 @@ public ModDetail getModDetails(ModItem item) {
versionNames[i] = modDetail.get("displayName").getAsString();
JsonElement downloadUrl = modDetail.get("downloadUrl");
- versionUrls[i] = downloadUrl.getAsString();
+ if (downloadUrl == null || downloadUrl.isJsonNull()) {
+ versionUrls[i] = null;
+ } else {
+ versionUrls[i] = downloadUrl.getAsString();
+ }
JsonArray gameVersions = modDetail.getAsJsonArray("gameVersions");
for(JsonElement jsonElement : gameVersions) {
@@ -137,6 +220,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE
return ModpackInstaller.installModpack(modDetail, selectedVersion, this::installCurseforgeZip);
}
+ @Override
+ public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException {
+ return ModpackInstaller.importModpack(activity, zipUri, this::installCurseforgeZip);
+ }
+
private int getPaginatedDetails(ArrayList objectList, int index, String modId) {
HashMap params = new HashMap<>();
@@ -211,6 +299,9 @@ private ModLoader createInfo(CurseManifest.CurseMinecraft minecraft) {
case "fabric":
modLoaderTypeInt = ModLoader.MOD_LOADER_FABRIC;
break;
+ case "neoforge":
+ modLoaderTypeInt = ModLoader.MOD_LOADER_NEOFORGE;
+ break;
default:
return null;
//TODO: Quilt is also Forge? How does that work?
@@ -271,4 +362,4 @@ private boolean verifyManifest(CurseManifest manifest) {
static class CurseforgeSearchResult extends SearchResult {
int previousOffset;
}
-}
+}
\ No newline at end of file
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
index 1eef3567b4..59f1dd28a1 100644
--- 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
@@ -9,6 +9,7 @@
import net.kdt.pojavlaunch.modloaders.ForgeDownloadTask;
import net.kdt.pojavlaunch.modloaders.ForgeUtils;
import net.kdt.pojavlaunch.modloaders.ModloaderDownloadListener;
+import net.kdt.pojavlaunch.modloaders.NeoForgeDownloadTask;
import java.io.File;
@@ -16,6 +17,7 @@ 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 static final int MOD_LOADER_NEOFORGE = 3;
public final int modLoaderType;
public final String modLoaderVersion;
public final String minecraftVersion;
@@ -38,6 +40,8 @@ public String getVersionId() {
return "fabric-loader-"+modLoaderVersion+"-"+minecraftVersion;
case MOD_LOADER_QUILT:
return "quilt-loader-"+modLoaderVersion+"-"+minecraftVersion;
+ case MOD_LOADER_NEOFORGE:
+ return "neoforge-"+modLoaderVersion;
default:
return null;
}
@@ -57,6 +61,8 @@ public Runnable getDownloadTask(ModloaderDownloadListener listener) {
return createFabriclikeTask(listener, FabriclikeUtils.FABRIC_UTILS);
case MOD_LOADER_QUILT:
return createFabriclikeTask(listener, FabriclikeUtils.QUILT_UTILS);
+ case MOD_LOADER_NEOFORGE:
+ return new NeoForgeDownloadTask(listener, modLoaderVersion);
default:
return null;
}
@@ -77,6 +83,11 @@ public Intent getInstallationIntent(Context context, File modInstallerJar) {
case MOD_LOADER_FORGE:
ForgeUtils.addAutoInstallArgs(baseIntent, modInstallerJar, getVersionId());
return baseIntent;
+ case MOD_LOADER_NEOFORGE:
+ return baseIntent
+ .putExtra("javaArgs", "-jar "+modInstallerJar.getAbsolutePath()+" --install-client")
+ .putExtra("openLogOutput", true)
+ ;
case MOD_LOADER_QUILT:
case MOD_LOADER_FABRIC:
default:
@@ -91,6 +102,7 @@ public Intent getInstallationIntent(Context context, File modInstallerJar) {
public boolean requiresGuiInstallation() {
switch (modLoaderType) {
case MOD_LOADER_FORGE:
+ case MOD_LOADER_NEOFORGE:
return true;
case MOD_LOADER_FABRIC:
case MOD_LOADER_QUILT:
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
index 141468af88..a6ea9c8dca 100644
--- 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
@@ -1,7 +1,9 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api;
+import android.app.Activity;
import android.content.Context;
+import android.net.Uri;
import com.kdt.mcgui.ProgressLayout;
@@ -12,8 +14,12 @@
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.modloaders.modpacks.models.Constants;
import java.io.IOException;
+import java.io.File;
+import java.security.NoSuchAlgorithmException;
+
/**
*
@@ -70,4 +76,13 @@ default void handleInstallation(Context context, ModDetail modDetail, int select
* @param selectedVersion The selected version
*/
ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOException;
+
+ /**
+ * Imports the mod(pack) from a file.
+ * May require the download of additional files.
+ * May requires launching the installation of a modloader
+ * @param activity any activity
+ * @param zipUri URI to DocumentsUI selected zip file
+ */
+ ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException;
}
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
index 048458d30a..0c614b8ca7 100644
--- 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
@@ -1,5 +1,10 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api;
+import android.app.Activity;
+import android.net.Uri;
+
+import com.google.gson.JsonObject;
+import com.google.gson.JsonParser;
import com.kdt.mcgui.ProgressLayout;
import net.kdt.pojavlaunch.R;
@@ -11,10 +16,20 @@
import net.kdt.pojavlaunch.value.launcherprofiles.LauncherProfiles;
import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile;
+
+import java.io.BufferedReader;
import java.io.File;
+import java.io.FileOutputStream;
import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.security.DigestInputStream;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.Locale;
import java.util.concurrent.Callable;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipInputStream;
public class ModpackInstaller {
@@ -69,7 +84,94 @@ public static ModLoader installModpack(ModDetail modDetail, int selectedVersion,
return modLoaderInfo;
}
- interface InstallFunction {
+ public static ModLoader importModpack(Activity activity, Uri zipUri, InstallFunction installFunction) throws IOException, NoSuchAlgorithmException {
+ String modrinthPackInfoFileName = "modrinth.index.json";
+ String curseforgePackInfoFileName = "manifest.json";
+ InputStream inputStream = null;
+ inputStream = activity.getContentResolver().openInputStream(zipUri);
+ ZipInputStream zipInputStream = new ZipInputStream(inputStream);
+ ZipEntry zipEntry;
+ while ((zipEntry = zipInputStream.getNextEntry()) != null) {
+ boolean isModrinth = zipEntry.getName().equals(modrinthPackInfoFileName);
+ boolean isCurseforge = zipEntry.getName().equals(curseforgePackInfoFileName);
+ if (!(isModrinth || isCurseforge)) continue;
+ // Read Manifest JSON
+ BufferedReader reader = new BufferedReader(new InputStreamReader(zipInputStream));
+ String str;
+ StringBuilder jsonString = new StringBuilder();
+ while ((str = reader.readLine()) != null) {
+ jsonString.append(str).append("\n");
+ }
+ zipInputStream.close();
+
+ // Hash the ZIP File
+ inputStream = activity.getContentResolver().openInputStream(zipUri);
+ MessageDigest algorithm = MessageDigest.getInstance("SHA-1");
+ DigestInputStream hashingStream = new DigestInputStream(inputStream, algorithm);
+
+ byte[] buffer = new byte[8192];
+ while (hashingStream.read(buffer) != -1) {} // just read to update the digest
+ hashingStream.close();
+ byte[] digest = algorithm.digest();
+ StringBuilder sb = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ String hash = sb.toString();
+
+ // Parse the JSON to prepare for instance creation
+ JsonObject packInfoJson = JsonParser.parseString(jsonString.toString()).getAsJsonObject();
+ String modpackName;
+ if(isModrinth){
+ // Added a for because there is an awkward __ that I can't be bothered to fix
+ // FO only deduplication be like:
+ modpackName = (packInfoJson.get("name").getAsString().toLowerCase(Locale.ROOT) +
+ packInfoJson.get("versionId") + "for" +
+ packInfoJson.get("dependencies").getAsJsonObject().get("minecraft"));
+ } else {
+ modpackName = (packInfoJson.get("name").getAsString().toLowerCase(Locale.ROOT) +
+ packInfoJson.get("version") + "for" +
+ packInfoJson.get("minecraft").getAsJsonObject().get("version"));
+ }
+ modpackName = modpackName.trim().replaceAll("[\\\\/:*?\"<>| \\t\\n]", "_");
+ modpackName = modpackName + hash;
+
+ // Copy ZIP file to cache
+ if(modpackName == null) throw new IOException("Corrupt Modpack manifest file.");
+ File modpackFile = null;
+ modpackFile = new File(Tools.DIR_CACHE, modpackName + ".cf");
+ inputStream = activity.getContentResolver().openInputStream(zipUri);
+ FileOutputStream output = new FileOutputStream(modpackFile);
+ byte[] b = new byte[4 * 1024];
+ int read;
+ while ((read = inputStream.read(b)) != -1) {
+ output.write(b, 0, read);
+ }
+ output.flush();
+
+ // Install the actual pack into custom_instances
+ ModLoader modLoaderInfo = installFunction.installModpack(modpackFile, new File(Tools.DIR_GAME_HOME, "custom_instances/"+modpackName));
+ // We have to do this because installModpack doesn't clean up after itself
+ ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK);
+ modpackFile.delete();
+ if(modLoaderInfo == null) {
+ return null;
+ }
+
+ // Create the instance (We don't have a picture guys)
+ MinecraftProfile profile = new MinecraftProfile();
+ profile.gameDir = "./custom_instances/" + modpackName;
+ profile.name = packInfoJson.get("name").getAsString();
+ profile.lastVersionId = modLoaderInfo.getVersionId();
+ LauncherProfiles.mainProfileJson.profiles.put(modpackName, profile);
+ LauncherProfiles.write();
+
+ return modLoaderInfo;
+ }
+ throw new IOException("Can't find manifest file in modpack provided");
+}
+
+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
index 73a5863f6e..78e48fc10f 100644
--- 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
@@ -1,5 +1,8 @@
package net.kdt.pojavlaunch.modloaders.modpacks.api;
+import android.app.Activity;
+import android.net.Uri;
+
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.kdt.mcgui.ProgressLayout;
@@ -17,6 +20,7 @@
import java.io.File;
import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
import java.util.HashMap;
import java.util.Map;
import java.util.zip.ZipFile;
@@ -48,6 +52,8 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous
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));
+ if(searchFilters.modLoader != null && !searchFilters.modLoader.isEmpty())
+ facetString.append(String.format(",[\"categories:%s\"]", searchFilters.modLoader));
facetString.append("]");
params.put("facets", facetString.toString());
params.put("query", searchFilters.name);
@@ -82,32 +88,88 @@ public SearchResult searchMod(SearchFilters searchFilters, SearchResult previous
@Override
public ModDetail getModDetails(ModItem item) {
+ return getModDetails(item, null, null);
+ }
+ public ModDetail getModDetails(ModItem item, String filterMcVersion) {
+ return getModDetails(item, filterMcVersion, null);
+ }
+
+ public ModDetail getModDetails(ModItem item, String filterMcVersion, String filterLoader) {
JsonArray response = mApiHandler.get(String.format("project/%s/version", item.id), JsonArray.class);
if(response == null) return null;
- System.out.println(response);
- String[] names = new String[response.size()];
- String[] mcNames = new String[response.size()];
- String[] urls = new String[response.size()];
- String[] hashes = new String[response.size()];
-
- for (int i=0; i versions = new java.util.ArrayList<>();
+ for (int i = 0; i < response.size(); i++) {
+ JsonObject v = response.get(i).getAsJsonObject();
+ if (filterMcVersion != null && !filterMcVersion.isEmpty()) {
+ JsonArray gameVersions = v.get("game_versions").getAsJsonArray();
+ boolean matches = false;
+ for (int j = 0; j < gameVersions.size(); j++) {
+ if (filterMcVersion.equals(gameVersions.get(j).getAsString())) {
+ matches = true;
+ break;
+ }
+ }
+ if (!matches) continue;
+ }
+ if (filterLoader != null && !filterLoader.isEmpty()) {
+ JsonArray loaders = v.get("loaders").getAsJsonArray();
+ boolean matches = false;
+ for (int j = 0; j < loaders.size(); j++) {
+ if (filterLoader.equalsIgnoreCase(loaders.get(j).getAsString())) {
+ matches = true;
+ break;
+ }
+ }
+ if (!matches) continue;
+ }
+ versions.add(v);
+ }
+
+ if (versions.isEmpty()) return null;
+
+ int size = versions.size();
+ String[] names = new String[size];
+ String[] mcNames = new String[size];
+ String[] urls = new String[size];
+ String[] hashes = new String[size];
+ String[][] depIds = new String[size][];
+ String[][] depTypes = new String[size][];
+
+ for (int i = 0; i < size; i++) {
+ JsonObject version = versions.get(i);
+ names[i] = version.get("name").getAsString();
mcNames[i] = version.get("game_versions").getAsJsonArray().get(0).getAsString();
- urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString();
- // Assume there may not be hashes, in case the API changes
+ urls[i] = version.get("files").getAsJsonArray().get(0).getAsJsonObject().get("url").getAsString();
+
JsonObject hashesMap = version.getAsJsonArray("files").get(0).getAsJsonObject()
.get("hashes").getAsJsonObject();
- if(hashesMap == null || hashesMap.get("sha1") == null){
- hashes[i] = null;
- continue;
+ hashes[i] = (hashesMap == null || hashesMap.get("sha1") == null) ? null
+ : hashesMap.get("sha1").getAsString();
+
+ // Capture dependencies
+ if (version.has("dependencies") && !version.get("dependencies").isJsonNull()) {
+ JsonArray deps = version.getAsJsonArray("dependencies");
+ java.util.List ids = new java.util.ArrayList<>();
+ java.util.List types = new java.util.ArrayList<>();
+ for (int j = 0; j < deps.size(); j++) {
+ JsonObject dep = deps.get(j).getAsJsonObject();
+ if (dep.has("project_id") && !dep.get("project_id").isJsonNull()) {
+ ids.add(dep.get("project_id").getAsString());
+ types.add(dep.has("dependency_type") ? dep.get("dependency_type").getAsString() : "required");
+ }
+ }
+ depIds[i] = ids.toArray(new String[0]);
+ depTypes[i] = types.toArray(new String[0]);
+ } else {
+ depIds[i] = new String[0];
+ depTypes[i] = new String[0];
}
-
- hashes[i] = hashesMap.get("sha1").getAsString();
}
- return new ModDetail(item, names, mcNames, urls, hashes);
+ return new ModDetail(item, names, mcNames, urls, hashes, depIds, depTypes);
}
@Override
@@ -116,6 +178,11 @@ public ModLoader installMod(ModDetail modDetail, int selectedVersion) throws IOE
return ModpackInstaller.installModpack(modDetail, selectedVersion, this::installMrpack);
}
+ @Override
+ public ModLoader importModpack(Activity activity, Uri zipUri) throws IOException, NoSuchAlgorithmException {
+ return ModpackInstaller.importModpack(activity, zipUri, this::installMrpack);
+ }
+
private static ModLoader createInfo(ModrinthIndex modrinthIndex) {
if(modrinthIndex == null) return null;
Map dependencies = modrinthIndex.dependencies;
@@ -131,6 +198,9 @@ private static ModLoader createInfo(ModrinthIndex modrinthIndex) {
if((modLoaderVersion = dependencies.get("quilt-loader")) != null) {
return new ModLoader(ModLoader.MOD_LOADER_QUILT, modLoaderVersion, mcVersion);
}
+ if((modLoaderVersion = dependencies.get("neoforge")) != null) {
+ return new ModLoader(ModLoader.MOD_LOADER_NEOFORGE, modLoaderVersion, mcVersion);
+ }
return null;
}
@@ -156,4 +226,4 @@ private ModLoader installMrpack(File mrpackFile, File instanceDestination) throw
class ModrinthSearchResult extends SearchResult {
int previousOffset;
}
-}
+}
\ No newline at end of file
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
index 5a54e78b75..d080a2669a 100644
--- 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
@@ -12,16 +12,30 @@ public class ModDetail extends ModItem {
public String[] versionUrls;
/* SHA 1 hashes, null if a hash is unavailable */
public String[] versionHashes;
+ /* Per-version dependency project IDs */
+ public String[][] versionDependencyIds;
+ /* Per-version dependency types — "required" or "optional" */
+ public String[][] versionDependencyTypes;
+
public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls, String[] hashes) {
+ this(item, versionNames, mcVersionNames, versionUrls, hashes, null, null);
+ }
+
+ public ModDetail(ModItem item, String[] versionNames, String[] mcVersionNames, String[] versionUrls, String[] hashes,
+ String[][] depIds, String[][] depTypes) {
super(item.apiSource, item.isModpack, item.id, item.title, item.description, item.imageUrl);
+ this.isRestricted = item.isRestricted;
+ this.websiteUrl = item.websiteUrl;
this.versionNames = versionNames;
this.mcVersionNames = mcVersionNames;
this.versionUrls = versionUrls;
this.versionHashes = hashes;
+ this.versionDependencyIds = depIds;
+ this.versionDependencyTypes = depTypes;
// Add the mc version to the version model
for (int i=0; i installJvmLauncher) {
mDialogView = new RecyclerView(activity);
mDialogView.setLayoutManager(new LinearLayoutManager(activity, LinearLayoutManager.VERTICAL, false));
RTRecyclerViewAdapter adapter = new RTRecyclerViewAdapter();
+ adapter.setDialog(get());
mDialogView.setAdapter(adapter);
mDialog = new AlertDialog.Builder(activity)
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java
index 09e602cd4a..e09ff738bf 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/MultiRTUtils.java
@@ -1,5 +1,6 @@
package net.kdt.pojavlaunch.multirt;
+import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture;
import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR;
import static org.apache.commons.io.FileUtils.listFiles;
@@ -8,6 +9,8 @@
import com.kdt.mcgui.ProgressLayout;
+import net.kdt.pojavlaunch.Architecture;
+import net.kdt.pojavlaunch.NewJREUtil.ExternalRuntime;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.utils.MathUtils;
@@ -36,7 +39,7 @@ public class MultiRTUtils {
private static final String JAVA_VERSION_STR = "JAVA_VERSION=\"";
private static final String OS_ARCH_STR = "OS_ARCH=\"";
- public static List getRuntimes() {
+ public static List getInstalledRuntimes() {
if(!RUNTIME_FOLDER.exists() && !RUNTIME_FOLDER.mkdirs()) {
throw new RuntimeException("Failed to create runtime directory");
}
@@ -51,8 +54,25 @@ public static List getRuntimes() {
return runtimes;
}
+ /**
+ *
+ * @return Java versions which are not installed but are present in {@link ExternalRuntime}
+ */
+ public static List getRuntimesToDownload() {
+ List runtimesToDownload = new ArrayList<>();
+ ExternalRuntime[] downloadableRuntimes = ExternalRuntime.values();
+ for (ExternalRuntime downloadableruntime : downloadableRuntimes) {
+ if(getExactJreName(downloadableruntime.majorVersion) == null){
+ // x86 isn't supported anymore for JRE25
+ if (!(getDeviceArchitecture() == Architecture.ARCH_X86 && downloadableruntime.majorVersion >= 21))
+ runtimesToDownload.add(downloadableruntime);
+ }
+ }
+ return runtimesToDownload;
+ }
+
public static String getExactJreName(int majorVersion) {
- List runtimes = getRuntimes();
+ List runtimes = getInstalledRuntimes();
for(Runtime r : runtimes)
if(r.javaVersion == majorVersion)return r.name;
@@ -60,7 +80,7 @@ public static String getExactJreName(int majorVersion) {
}
public static String getNearestJreName(int majorVersion) {
- List runtimes = getRuntimes();
+ List runtimes = getInstalledRuntimes();
MathUtils.RankedValue nearestRankedRuntime = MathUtils.findNearestPositive(majorVersion, runtimes, (runtime)->runtime.javaVersion);
if(nearestRankedRuntime == null) return null;
Runtime nearestRuntime = nearestRankedRuntime.value;
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java
index 02e4072e2f..00efae87e9 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/multirt/RTRecyclerViewAdapter.java
@@ -18,6 +18,7 @@
import androidx.recyclerview.widget.RecyclerView;
import net.kdt.pojavlaunch.Architecture;
+import net.kdt.pojavlaunch.NewJREUtil;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.prefs.LauncherPreferences;
@@ -28,6 +29,7 @@
public class RTRecyclerViewAdapter extends RecyclerView.Adapter {
private boolean mIsDeleting = false;
+ private MultiRTConfigDialog dialog;
@NonNull
@Override
@@ -38,13 +40,18 @@ public RTViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType)
@Override
public void onBindViewHolder(@NonNull RTViewHolder holder, int position) {
- final List runtimes = MultiRTUtils.getRuntimes();
- holder.bindRuntime(runtimes.get(position),position);
+ final List installedRuntimes = MultiRTUtils.getInstalledRuntimes();
+ final List downloadableRuntimes = MultiRTUtils.getRuntimesToDownload();
+ if (installedRuntimes.size() > position) {
+ holder.bindInstalledRuntime(installedRuntimes.get(position),position);
+ } else if (installedRuntimes.size() + downloadableRuntimes.size() > position) {
+ holder.bindDownloadableRuntime(downloadableRuntimes.get(position - installedRuntimes.size()), position);
+ }
}
@Override
public int getItemCount() {
- return MultiRTUtils.getRuntimes().size();
+ return MultiRTUtils.getInstalledRuntimes().size() + MultiRTUtils.getRuntimesToDownload().size();
}
public boolean isDefaultRuntime(Runtime rt) {
@@ -68,6 +75,10 @@ public boolean getIsEditing(){
return mIsDeleting;
}
+ public void setDialog(MultiRTConfigDialog multiRTConfigDialog) {
+ this.dialog = multiRTConfigDialog;
+ }
+
public class RTViewHolder extends RecyclerView.ViewHolder {
final TextView mJavaVersionTextView;
@@ -104,7 +115,7 @@ private void setupOnClickListeners(){
mDeleteButton.setOnClickListener(v -> {
if (mCurrentRuntime == null) return;
- if(MultiRTUtils.getRuntimes().size() < 2) {
+ if(MultiRTUtils.getInstalledRuntimes().size() < 2) {
new AlertDialog.Builder(mContext)
.setTitle(R.string.global_error)
.setMessage(R.string.multirt_config_removeerror_last)
@@ -129,7 +140,7 @@ private void setupOnClickListeners(){
});
}
- public void bindRuntime(Runtime runtime, int pos) {
+ public void bindInstalledRuntime(Runtime runtime, int pos) {
mCurrentRuntime = runtime;
mCurrentPosition = pos;
if(runtime.versionString != null && Tools.DEVICE_ARCHITECTURE == Architecture.archAsInt(runtime.arch)) {
@@ -159,6 +170,48 @@ public void bindRuntime(Runtime runtime, int pos) {
mSetDefaultButton.setVisibility(View.GONE);
}
+ @SuppressLint("NotifyDataSetChanged")
+ public void bindDownloadableRuntime(NewJREUtil.ExternalRuntime runtime, int pos) {
+ mCurrentPosition = pos;
+ mJavaVersionTextView.setText(runtime.name
+ .replace(".tar.xz", "")
+ .replace("-", " "));
+ mFullJavaVersionTextView.setText(R.string.global_not_installed);
+ mFullJavaVersionTextView.setTextColor(mDefaultColors);
+ mSetDefaultButton.setVisibility(View.VISIBLE);
+ mDeleteButton.setVisibility(View.GONE);
+
+ if (runtime.isDownloading) {
+ mSetDefaultButton.setEnabled(false);
+ mSetDefaultButton.setText(R.string.global_installing);
+ } else {
+ mSetDefaultButton.setEnabled(true);
+ mSetDefaultButton.setText(R.string.global_download);
+ }
+
+ mSetDefaultButton.setOnClickListener(v -> {
+ runtime.isDownloading = true;
+ mSetDefaultButton.setEnabled(false);
+ mSetDefaultButton.setText(R.string.global_download);
+ sExecutorService.execute(() -> {
+ mSetDefaultButton.setText(R.string.global_installing);
+ try {
+ runtime.downloadRuntime(v.getContext());
+ } catch (RuntimeException e) {
+ Tools.showErrorRemote(e);
+ }
+ v.post(() -> {
+ // Reset the listener for this button so SET DEFAULT actually sets default
+ setupOnClickListeners();
+ // Update the UI so it knows it got installed
+ notifyDataSetChanged();
+ runtime.isDownloading = false;
+ });
+ });
+ });
+
+ }
+
private void updateButtonsVisibility(){
mSetDefaultButton.setVisibility(mIsDeleting ? View.GONE : View.VISIBLE);
mDeleteButton.setVisibility(mIsDeleting ? View.VISIBLE : View.GONE);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java
new file mode 100644
index 0000000000..0cd0538679
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/plugins/LibraryPlugin.java
@@ -0,0 +1,56 @@
+package net.kdt.pojavlaunch.plugins;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.util.Log;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.Map;
+
+public class LibraryPlugin {
+ private static final String TAG = "LibraryPlugin";
+
+ // Known plugins constants
+ public static final String ID_ANGLE_PLUGIN = "git.mojo.angle";
+ public static final String ID_FFMPEG_PLUGIN = "git.mojo.ffmpeg";
+
+ private String appId;
+ private String libraryPath;
+ private LibraryPlugin(String app, String libraryPath){
+ this.appId = app;
+ this.libraryPath = libraryPath;
+ }
+ public static LibraryPlugin discoverPlugin(Context ctx, String appId){
+
+ String libraryPath;
+ try {
+ PackageInfo pluginPackage = ctx.getPackageManager().getPackageInfo(appId, PackageManager.GET_SHARED_LIBRARY_FILES);
+ libraryPath = pluginPackage.applicationInfo.nativeLibraryDir;
+
+ } catch (Exception e){
+ Log.e(TAG, "Plugin discover failed: " + e.getMessage());
+ return null;
+ }
+ return new LibraryPlugin(appId, libraryPath);
+ }
+
+ public String getId(){
+ return appId;
+ }
+
+ public String getLibraryPath(){
+ return libraryPath;
+ }
+ public String resolveAbsolutePath(String library) {
+ return new File(libraryPath, library).getAbsolutePath();
+ }
+
+ public boolean checkLibraries(String... libs){
+ for(String lib : libs){
+ if(!(new File(libraryPath, lib).exists())) return false;
+ }
+ return true;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java
new file mode 100644
index 0000000000..b276122316
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/CustomListSummaryProvider.java
@@ -0,0 +1,16 @@
+package net.kdt.pojavlaunch.prefs;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+
+public class CustomListSummaryProvider implements Preference.SummaryProvider {
+ @Nullable
+ public CharSequence provideSummary(@NonNull Preference preference) {
+ if (preference.hasKey())
+ preference.setSummary(preference.getKey());
+ else preference.setSummary("@string/mcl_setting_title_renderer_settings");
+ return null;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java
index e7796c34a4..424cfe3db0 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/LauncherPreferences.java
@@ -15,16 +15,18 @@
import net.kdt.pojavlaunch.*;
import net.kdt.pojavlaunch.multirt.MultiRTUtils;
+import net.kdt.pojavlaunch.utils.FileUtils;
import net.kdt.pojavlaunch.utils.JREUtils;
+import java.io.File;
import java.io.IOException;
+import java.util.LinkedHashMap;
public class LauncherPreferences {
public static final String PREF_KEY_CURRENT_PROFILE = "currentProfile";
public static final String PREF_KEY_SKIP_NOTIFICATION_CHECK = "skipNotificationPermissionCheck";
public static SharedPreferences DEFAULT_PREF;
- public static String PREF_RENDERER = "opengles2";
public static boolean PREF_IGNORE_NOTCH = false;
public static int PREF_NOTCH_SIZE = 0;
@@ -37,6 +39,8 @@ public class LauncherPreferences {
public static final String PREF_VERSION_REPOS = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json";
public static boolean PREF_CHECK_LIBRARY_SHA = true;
public static boolean PREF_DISABLE_GESTURES = false;
+ public static boolean PREF_GAMEPAD_SDL_PASSTHRU = false;
+ public static boolean PREF_GAMEPAD_FORCEDSDL_PASSTHRU = false;
public static boolean PREF_DISABLE_SWAP_HAND = false;
public static float PREF_MOUSESPEED = 1f;
public static int PREF_RAM_ALLOCATION;
@@ -67,6 +71,10 @@ public class LauncherPreferences {
public static String PREF_DOWNLOAD_SOURCE = "default";
public static boolean PREF_SKIP_NOTIFICATION_PERMISSION_CHECK = false;
public static boolean PREF_VSYNC_IN_ZINK = true;
+ public static boolean PREF_FORCE_ENABLE_TOUCHCONTROLLER = false;
+ public static int PREF_TOUCHCONTROLLER_VIBRATE_LENGTH = 100;
+
+ public static boolean PREF_MOUSE_GRAB_FORCE = false;
public static void loadPreferences(Context ctx) {
@@ -74,7 +82,6 @@ public static void loadPreferences(Context ctx) {
Tools.initStorageConstants(ctx);
boolean isDevicePowerful = isDevicePowerful(ctx);
- PREF_RENDERER = DEFAULT_PREF.getString("renderer", "opengles2");
PREF_BUTTONSIZE = DEFAULT_PREF.getInt("buttonscale", 100);
PREF_MOUSESCALE = DEFAULT_PREF.getInt("mousescale", 100)/100f;
PREF_MOUSESPEED = ((float)DEFAULT_PREF.getInt("mousespeed",100))/100f;
@@ -84,6 +91,8 @@ public static void loadPreferences(Context ctx) {
PREF_FORCE_ENGLISH = DEFAULT_PREF.getBoolean("force_english", false);
PREF_CHECK_LIBRARY_SHA = DEFAULT_PREF.getBoolean("checkLibraries",true);
PREF_DISABLE_GESTURES = DEFAULT_PREF.getBoolean("disableGestures",false);
+ PREF_GAMEPAD_SDL_PASSTHRU = DEFAULT_PREF.getBoolean("gamepadPassthru",false);
+ PREF_GAMEPAD_FORCEDSDL_PASSTHRU = DEFAULT_PREF.getBoolean("gamepadPassthruForced",false);
PREF_DISABLE_SWAP_HAND = DEFAULT_PREF.getBoolean("disableDoubleTap", false);
PREF_RAM_ALLOCATION = DEFAULT_PREF.getInt("allocation", findBestRAMAllocation(ctx));
PREF_CUSTOM_JAVA_ARGS = DEFAULT_PREF.getString("javaArgs", "");
@@ -109,6 +118,9 @@ public static void loadPreferences(Context ctx) {
PREF_VERIFY_MANIFEST = DEFAULT_PREF.getBoolean("verifyManifest", true);
PREF_SKIP_NOTIFICATION_PERMISSION_CHECK = DEFAULT_PREF.getBoolean(PREF_KEY_SKIP_NOTIFICATION_CHECK, false);
PREF_VSYNC_IN_ZINK = DEFAULT_PREF.getBoolean("vsync_in_zink", true);
+ PREF_FORCE_ENABLE_TOUCHCONTROLLER = DEFAULT_PREF.getBoolean("forceEnableTouchController", false);
+ PREF_TOUCHCONTROLLER_VIBRATE_LENGTH = DEFAULT_PREF.getInt("touchControllerVibrateLength", 100);
+ PREF_MOUSE_GRAB_FORCE = DEFAULT_PREF.getBoolean("always_grab_mouse", false);
String argLwjglLibname = "-Dorg.lwjgl.opengl.libname=";
for (String arg : JREUtils.parseJavaArguments(PREF_CUSTOM_JAVA_ARGS)) {
@@ -121,11 +133,11 @@ public static void loadPreferences(Context ctx) {
if(DEFAULT_PREF.contains("defaultRuntime")) {
PREF_DEFAULT_RUNTIME = DEFAULT_PREF.getString("defaultRuntime","");
}else{
- if(MultiRTUtils.getRuntimes().isEmpty()) {
+ if(MultiRTUtils.getInstalledRuntimes().isEmpty()) {
PREF_DEFAULT_RUNTIME = "";
return;
}
- PREF_DEFAULT_RUNTIME = MultiRTUtils.getRuntimes().get(0).name;
+ PREF_DEFAULT_RUNTIME = MultiRTUtils.getInstalledRuntimes().get(0).name;
LauncherPreferences.DEFAULT_PREF.edit().putString("defaultRuntime",LauncherPreferences.PREF_DEFAULT_RUNTIME).apply();
}
}
@@ -216,4 +228,39 @@ public static void computeNotchSize(Activity activity) {
}
Tools.updateWindowSize(activity);
}
-}
+ public static void writeMGRendererSettings() throws IOException {
+ LinkedHashMap MGConfigJson = new LinkedHashMap<>();
+ // Copying the defaultValues from pref_renderer.xml to use as defaults here too
+
+ // We need to get the string and convert it to int because the android:defaultValues only takes in string-arrays.
+ // Using .getInt() leads to a class cast exception and using integer-arrays will just crash the layout/fragment.
+ MGConfigJson.put("enableANGLE", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_angle", "0")));
+ MGConfigJson.put("enableNoError", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_errorSetting", "0")));
+ MGConfigJson.put("fsr1Setting", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_fsr", "0")));
+
+ // These guys are SwitchPreferences so they get special treatment, they need to be converted to ints
+ int gl43exts = DEFAULT_PREF.getBoolean("mg_renderer_setting_gl43ext", false) ? 1 : 0;
+ int computeShaderext = DEFAULT_PREF.getBoolean("mg_renderer_computeShaderext", false) ? 1 : 0;
+ int angleDepthClearFixMode = DEFAULT_PREF.getBoolean("mg_renderer_setting_angleDepthClearFixMode", false) ? 1 : 0;
+ int timerQueryExt = DEFAULT_PREF.getBoolean("mg_renderer_setting_timerQueryExt", false) ? 1 : 0;
+ int dsaExt = DEFAULT_PREF.getBoolean("mg_renderer_dsaExt", false) ? 1 : 0;
+ MGConfigJson.put("enableExtGL43", gl43exts);
+ MGConfigJson.put("enableExtComputeShader", computeShaderext);
+ MGConfigJson.put("angleDepthClearFixMode", angleDepthClearFixMode);
+ MGConfigJson.put("enableExtTimerQuery", timerQueryExt);
+ MGConfigJson.put("enableExtDirectStateAccess", dsaExt);
+ if (DEFAULT_PREF.getBoolean("mg_renderer_multidrawCompute", false)) {
+ MGConfigJson.put("multidrawMode", 5); // Special handling for the (special mayhaps) compute emulation
+ } else MGConfigJson.put("multidrawMode", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_multidraw", "0")));
+ MGConfigJson.put("maxGlslCacheSize", Integer.parseInt(DEFAULT_PREF.getString("mg_renderer_setting_glsl_cache_size", "128")));
+ File configFile = new File(Tools.DIR_DATA + "/MobileGlues", "config.json");
+ FileUtils.ensureParentDirectory(configFile);
+ try {
+ Tools.write(configFile.getAbsolutePath(),Tools.GLOBAL_GSON.toJson(MGConfigJson));
+ Logger.appendToLog("Writing MG configs to " + configFile.getAbsolutePath());
+ Logger.appendToLog("MG Config is " + Tools.GLOBAL_GSON.toJson(MGConfigJson));
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java
index 5458dbf572..0689a7ce1d 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/QuickSettingSideDialog.java
@@ -7,6 +7,7 @@
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_GYRO_SENSITIVITY;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_LONGPRESS_TRIGGER;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSESPEED;
+import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_MOUSE_GRAB_FORCE;
import static net.kdt.pojavlaunch.prefs.LauncherPreferences.PREF_SCALE_FACTOR;
import android.annotation.SuppressLint;
@@ -31,11 +32,11 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView {
private SharedPreferences.Editor mEditor;
@SuppressLint("UseSwitchCompatOrMaterialCode")
- private Switch mGyroSwitch, mGyroXSwitch, mGyroYSwitch, mGestureSwitch;
+ private Switch mGyroSwitch, mGyroXSwitch, mGyroYSwitch, mGestureSwitch, mMouseGrabSwitch;
private CustomSeekbar mGyroSensitivityBar, mMouseSpeedBar, mGestureDelayBar, mResolutionBar;
private TextView mGyroSensitivityText, mGyroSensitivityDisplayText, mMouseSpeedText, mGestureDelayText, mGestureDelayDisplayText, mResolutionText;
- private boolean mOriginalGyroEnabled, mOriginalGyroXEnabled, mOriginalGyroYEnabled, mOriginalGestureDisabled;
+ private boolean mOriginalGyroEnabled, mOriginalGyroXEnabled, mOriginalGyroYEnabled, mOriginalGestureDisabled, mOriginalMouseGrab;
private float mOriginalGyroSensitivity, mOriginalMouseSpeed, mOriginalResolution;
private int mOriginalGestureDelay;
@@ -65,6 +66,7 @@ private void bindLayout() {
mGyroXSwitch = mDialogContent.findViewById(R.id.checkboxGyroX);
mGyroYSwitch = mDialogContent.findViewById(R.id.checkboxGyroY);
mGestureSwitch = mDialogContent.findViewById(R.id.checkboxGesture);
+ mMouseGrabSwitch = mDialogContent.findViewById(R.id.always_grab_mouse_side_dialog);
mGyroSensitivityBar = mDialogContent.findViewById(R.id.editGyro_seekbar);
mMouseSpeedBar = mDialogContent.findViewById(R.id.editMouseSpeed_seekbar);
@@ -86,6 +88,7 @@ private void setupListeners() {
mOriginalGyroXEnabled = PREF_GYRO_INVERT_X;
mOriginalGyroYEnabled = PREF_GYRO_INVERT_Y;
mOriginalGestureDisabled = PREF_DISABLE_GESTURES;
+ mOriginalMouseGrab = PREF_MOUSE_GRAB_FORCE;
mOriginalGyroSensitivity = PREF_GYRO_SENSITIVITY;
mOriginalMouseSpeed = PREF_MOUSESPEED;
@@ -96,6 +99,7 @@ private void setupListeners() {
mGyroXSwitch.setChecked(mOriginalGyroXEnabled);
mGyroYSwitch.setChecked(mOriginalGyroYEnabled);
mGestureSwitch.setChecked(mOriginalGestureDisabled);
+ mMouseGrabSwitch.setChecked(mOriginalMouseGrab);
mGyroSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
PREF_ENABLE_GYRO = isChecked;
@@ -122,6 +126,11 @@ private void setupListeners() {
mEditor.putBoolean("disableGestures", isChecked);
});
+ mMouseGrabSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> {
+ PREF_MOUSE_GRAB_FORCE = isChecked;
+ mEditor.putBoolean("always_grab_mouse", isChecked);
+ });
+
mGyroSensitivityBar.setOnSeekBarChangeListener((SimpleSeekBarListener) (seekBar, progress, fromUser) -> {
PREF_GYRO_SENSITIVITY = progress / 100f;
mEditor.putInt("gyroSensitivity", progress);
@@ -156,6 +165,7 @@ private void setupListeners() {
setSeekTextPercent(mResolutionText, mResolutionBar.getProgress());
+ updateMouseGrabVisibility();
updateGyroVisibility(mOriginalGyroEnabled);
updateGestureVisibility(mOriginalGestureDisabled);
}
@@ -172,6 +182,10 @@ private static void setSeekText(TextView target, int format, int value) {
target.setText(target.getContext().getString(format, value));
}
+ private void updateMouseGrabVisibility(){
+ mMouseGrabSwitch.setVisibility(Tools.isPointerDeviceConnected()? View.VISIBLE : View.GONE);
+ }
+
private void updateGyroVisibility(boolean isEnabled) {
int visibility = isEnabled ? View.VISIBLE : View.GONE;
mGyroXSwitch.setVisibility(visibility);
@@ -202,6 +216,7 @@ private void removeListeners() {
mGyroXSwitch.setOnCheckedChangeListener(null);
mGyroYSwitch.setOnCheckedChangeListener(null);
mGestureSwitch.setOnCheckedChangeListener(null);
+ mMouseGrabSwitch.setOnCheckedChangeListener(null);
mGyroSensitivityBar.setOnSeekBarChangeListener(null);
mMouseSpeedBar.setOnSeekBarChangeListener(null);
@@ -225,6 +240,7 @@ public void cancel() {
PREF_GYRO_INVERT_X = mOriginalGyroXEnabled;
PREF_GYRO_INVERT_Y = mOriginalGyroYEnabled;
PREF_DISABLE_GESTURES = mOriginalGestureDisabled;
+ PREF_MOUSE_GRAB_FORCE = mOriginalMouseGrab;
PREF_GYRO_SENSITIVITY = mOriginalGyroSensitivity;
PREF_MOUSESPEED = mOriginalMouseSpeed;
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java
index 7002f84526..bc793cba03 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceControlFragment.java
@@ -13,6 +13,7 @@
public class LauncherPreferenceControlFragment extends LauncherPreferenceFragment {
private boolean mGyroAvailable = false;
+
@Override
public void onCreatePreferences(Bundle b, String str) {
// Get values
@@ -20,6 +21,7 @@ public void onCreatePreferences(Bundle b, String str) {
int prefButtonSize = (int) LauncherPreferences.PREF_BUTTONSIZE;
int mouseScale = (int) (LauncherPreferences.PREF_MOUSESCALE * 100);
int gyroSampleRate = LauncherPreferences.PREF_GYRO_SAMPLE_RATE;
+ int touchControllerVibrateLength = LauncherPreferences.PREF_TOUCHCONTROLLER_VIBRATE_LENGTH;
float mouseSpeed = LauncherPreferences.PREF_MOUSESPEED;
float gyroSpeed = LauncherPreferences.PREF_GYRO_SENSITIVITY;
float joystickDeadzone = LauncherPreferences.PREF_DEADZONE_SCALE;
@@ -45,7 +47,7 @@ public void onCreatePreferences(Bundle b, String str) {
CustomSeekBarPreference seek6 = requirePreference("mousespeed",
CustomSeekBarPreference.class);
- seek6.setValue((int)(mouseSpeed *100f));
+ seek6.setValue((int) (mouseSpeed * 100f));
seek6.setSuffix(" %");
CustomSeekBarPreference deadzoneSeek = requirePreference("gamepad_deadzone_scale",
@@ -55,22 +57,29 @@ public void onCreatePreferences(Bundle b, String str) {
Context context = getContext();
- if(context != null) {
+ if (context != null) {
mGyroAvailable = Tools.deviceSupportsGyro(context);
}
- PreferenceCategory gyroCategory = requirePreference("gyroCategory",
+ PreferenceCategory gyroCategory = requirePreference("gyroCategory",
PreferenceCategory.class);
gyroCategory.setVisible(mGyroAvailable);
CustomSeekBarPreference gyroSensitivitySeek = requirePreference("gyroSensitivity",
CustomSeekBarPreference.class);
- gyroSensitivitySeek.setValue((int) (gyroSpeed*100f));
+ gyroSensitivitySeek.setValue((int) (gyroSpeed * 100f));
gyroSensitivitySeek.setSuffix(" %");
CustomSeekBarPreference gyroSampleRateSeek = requirePreference("gyroSampleRate",
CustomSeekBarPreference.class);
gyroSampleRateSeek.setValue(gyroSampleRate);
gyroSampleRateSeek.setSuffix(" ms");
+
+ CustomSeekBarPreference touchControllerVibrateLengthSeek = requirePreference(
+ "touchControllerVibrateLength",
+ CustomSeekBarPreference.class);
+ touchControllerVibrateLengthSeek.setValue(touchControllerVibrateLength);
+ touchControllerVibrateLengthSeek.setSuffix(" ms");
+
computeVisibility();
}
@@ -80,7 +89,7 @@ public void onSharedPreferenceChanged(SharedPreferences p, String s) {
computeVisibility();
}
- private void computeVisibility(){
+ private void computeVisibility() {
requirePreference("timeLongPressTrigger").setVisible(!LauncherPreferences.PREF_DISABLE_GESTURES);
requirePreference("gyroSensitivity").setVisible(LauncherPreferences.PREF_ENABLE_GYRO);
requirePreference("gyroSampleRate").setVisible(LauncherPreferences.PREF_ENABLE_GYRO);
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java
index 6b5ea17b87..83b3e5711b 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceExperimentalFragment.java
@@ -1,13 +1,143 @@
package net.kdt.pojavlaunch.prefs.screens;
+import android.app.AlertDialog;
+import android.net.Uri;
import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.preference.Preference;
+import androidx.preference.SwitchPreferenceCompat;
import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.fragments.MainMenuFragment;
+import net.kdt.pojavlaunch.fragments.RightPaneHomeFragment;
+import net.kdt.pojavlaunch.theme.ThemeManager;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+import static android.content.pm.ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
public class LauncherPreferenceExperimentalFragment extends LauncherPreferenceFragment {
+ private final ActivityResultLauncher mImagePickerLauncher =
+ registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> {
+ if (uri != null) copyImageToBgFile(uri);
+ });
+
@Override
public void onCreatePreferences(Bundle b, String str) {
addPreferencesFromResource(R.xml.pref_experimental);
+ setupForceLandscape();
+ setupCustomBackground();
+ setupColourTheme();
+ }
+
+ // ── Force landscape ───────────────────────────────────────────────────────
+
+ private void setupForceLandscape() {
+ SwitchPreferenceCompat pref = requirePreference("force_landscape", SwitchPreferenceCompat.class);
+ pref.setOnPreferenceChangeListener((preference, newValue) -> {
+ boolean force = Boolean.TRUE.equals(newValue);
+ requireActivity().setRequestedOrientation(
+ force ? SCREEN_ORIENTATION_SENSOR_LANDSCAPE : SCREEN_ORIENTATION_UNSPECIFIED);
+ return true;
+ });
+
+ SwitchPreferenceCompat gradientPref = requirePreference("enable_bg_gradient", SwitchPreferenceCompat.class);
+ gradientPref.setOnPreferenceChangeListener((preference, newValue) -> {
+ // Save explicitly before recreate — the framework saves after listener returns
+ net.kdt.pojavlaunch.prefs.LauncherPreferences.DEFAULT_PREF.edit()
+ .putBoolean(ThemeManager.KEY_GRADIENT, Boolean.TRUE.equals(newValue))
+ .commit(); // commit() not apply() — must be synchronous before recreate
+ requireActivity().recreate();
+ return true;
+ });
+ }
+
+ // ── Custom background ─────────────────────────────────────────────────────
+
+ private void setupCustomBackground() {
+ requirePreference("set_custom_launcher_bg").setOnPreferenceClickListener(p -> {
+ mImagePickerLauncher.launch("image/*");
+ return true;
+ });
+
+ requirePreference("remove_custom_launcher_bg").setOnPreferenceClickListener(p -> {
+ File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH);
+ if (bgFile.exists()) bgFile.delete();
+ notifyHomeFragmentBgChanged();
+ toast(R.string.preference_custom_bg_removed);
+ return true;
+ });
+ }
+
+ private void copyImageToBgFile(@NonNull Uri uri) {
+ File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH);
+ try (InputStream in = requireContext().getContentResolver().openInputStream(uri);
+ OutputStream out = new FileOutputStream(bgFile)) {
+ if (in == null) throw new Exception("Cannot open URI");
+ byte[] buf = new byte[8192];
+ int len;
+ while ((len = in.read(buf)) != -1) out.write(buf, 0, len);
+ notifyHomeFragmentBgChanged();
+ toast(R.string.preference_custom_bg_set_success);
+ } catch (Exception e) {
+ if (bgFile.exists()) bgFile.delete();
+ toast(R.string.preference_custom_bg_error);
+ }
+ }
+
+ // ── Colour theme ──────────────────────────────────────────────────────────
+
+ private void setupColourTheme() {
+ // Button 1: preset picker + reset
+ requirePreference("colour_theme_presets").setOnPreferenceClickListener(p -> {
+ showPresetDialog();
+ return true;
+ });
+ }
+
+ private void showPresetDialog() {
+ ThemeManager.Preset[] presets = ThemeManager.PRESETS;
+ String[] labels = new String[presets.length + 1];
+ for (int i = 0; i < presets.length; i++) labels[i] = presets[i].name;
+ labels[presets.length] = getString(R.string.preference_colour_reset);
+
+ new AlertDialog.Builder(requireContext())
+ .setTitle(R.string.preference_colour_presets_title)
+ .setItems(labels, (dialog, which) -> {
+ if (which < presets.length) {
+ ThemeManager.applyPreset(presets[which]);
+ } else {
+ ThemeManager.resetToDefault();
+ }
+ requireActivity().recreate();
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .show();
+ }
+
+ // ── Helpers ───────────────────────────────────────────────────────────────
+
+ private void toast(int resId) {
+ Toast.makeText(requireContext(), resId, Toast.LENGTH_SHORT).show();
+ }
+
+ private void notifyHomeFragmentBgChanged() {
+ MainMenuFragment mmf = (MainMenuFragment) requireActivity()
+ .getSupportFragmentManager()
+ .findFragmentByTag("ROOT");
+ if (mmf == null) return;
+ RightPaneHomeFragment home = (RightPaneHomeFragment) mmf
+ .getChildFragmentManager()
+ .findFragmentByTag(RightPaneHomeFragment.TAG);
+ if (home != null) home.reloadBackground();
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java
index 7275f86912..e46bc1b875 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java
@@ -23,7 +23,7 @@ public class LauncherPreferenceFragment extends PreferenceFragmentCompat impleme
@Override
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
- view.setBackgroundColor(getResources().getColor(R.color.background_app));
+ net.kdt.pojavlaunch.theme.ThemeManager.applyToPrefView(view);
super.onViewCreated(view, savedInstanceState);
}
@@ -35,6 +35,7 @@ public void onCreatePreferences(Bundle b, String str) {
private void setupNotificationRequestPreference() {
Preference mRequestNotificationPermissionPreference = requirePreference("notification_permission_request");
+ Preference mMicrophonePermissionPreference = requirePreference("microphone_permission_request");
Activity activity = getActivity();
if(activity instanceof LauncherActivity) {
LauncherActivity launcherActivity = (LauncherActivity)activity;
@@ -43,6 +44,11 @@ private void setupNotificationRequestPreference() {
launcherActivity.askForNotificationPermission(()->mRequestNotificationPermissionPreference.setVisible(false));
return true;
});
+ mMicrophonePermissionPreference.setVisible(!launcherActivity.checkForMicrophonePermission());
+ mMicrophonePermissionPreference.setOnPreferenceClickListener(preference -> {
+ launcherActivity.askForMicrophonePermission(()->mMicrophonePermissionPreference.setVisible(false));
+ return true;
+ });
}else{
mRequestNotificationPermissionPreference.setVisible(false);
}
@@ -78,4 +84,4 @@ protected T requirePreference(CharSequence key, Class
if(preferenceClass.isInstance(preference)) return (T)preference;
throw new IllegalStateException("Preference "+key+" is not an instance of "+preferenceClass.getSimpleName());
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java
index 975ec8e089..344630cdff 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceJavaFragment.java
@@ -7,7 +7,10 @@
import android.widget.TextView;
import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.Nullable;
import androidx.preference.EditTextPreference;
+import androidx.preference.Preference;
+import androidx.preference.SwitchPreference;
import net.kdt.pojavlaunch.R;
import net.kdt.pojavlaunch.Tools;
@@ -18,11 +21,20 @@
public class LauncherPreferenceJavaFragment extends LauncherPreferenceFragment {
private MultiRTConfigDialog mDialogScreen;
+ private SwitchPreference mSwitchAutoJRE;
private final ActivityResultLauncher mVmInstallLauncher =
registerForActivityResult(new OpenDocumentWithExtension("xz"), (data)->{
if(data != null) Tools.installRuntimeFromUri(getContext(), data);
});
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ mSwitchAutoJRE = findPreference("disable_autojre_select");
+ mSwitchAutoJRE.setSummary("Stops automatic selection of which runtime to use in \"" + getString(R.string.main_install_jar_file) + "\"");
+
+ }
+
@Override
public void onCreatePreferences(Bundle b, String str) {
int ramAllocation = LauncherPreferences.PREF_RAM_ALLOCATION;
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java
new file mode 100644
index 0000000000..63a25e543e
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceRendererSettingsFragment.java
@@ -0,0 +1,95 @@
+package net.kdt.pojavlaunch.prefs.screens;
+
+import static android.text.InputType.TYPE_CLASS_NUMBER;
+
+import android.content.SharedPreferences;
+import android.os.Bundle;
+import android.text.Editable;
+import android.text.TextWatcher;
+
+import androidx.preference.EditTextPreference;
+import androidx.preference.ListPreference;
+import androidx.preference.Preference;
+import androidx.preference.SwitchPreference;
+
+import net.kdt.pojavlaunch.R;
+
+import java.util.Objects;
+
+public class LauncherPreferenceRendererSettingsFragment extends LauncherPreferenceFragment {
+ EditTextPreference GLSLCachePreference;
+ ListPreference MultiDrawEmulationPreference;
+ SwitchPreference ComputeMultiDrawPreference;
+ Preference.SummaryProvider MultiDrawSummaryProvider;
+
+ @Override
+ public void onCreatePreferences(Bundle b, String str) {
+ addPreferencesFromResource(R.xml.pref_renderer);
+ GLSLCachePreference = findPreference("mg_renderer_setting_glsl_cache_size");
+ ComputeMultiDrawPreference = findPreference("mg_renderer_multidrawCompute");
+ MultiDrawEmulationPreference = findPreference("mg_renderer_setting_multidraw");
+ GLSLCachePreference.setOnBindEditTextListener((editText) -> {
+ editText.setInputType(TYPE_CLASS_NUMBER);
+ editText.addTextChangedListener(new TextWatcher() {
+ @Override
+ public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+ // Nothing, its boilerplate
+ }
+
+ @Override
+ public void afterTextChanged(Editable editable) {
+ // Nothing, its boilerplate
+ }
+
+ @Override
+ public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) {
+ // This is just to handle the summary not updating when its above max int value
+ // Horrible I know.
+ if (editText.getText().toString().isEmpty()) {
+ editText.setText("0");
+ }
+ if (Long.parseLong(editText.getText().toString()) > Integer.MAX_VALUE) {
+ editText.setError("Too big! Setting to maximum value");
+ editText.setText(String.valueOf(Integer.MAX_VALUE));
+ }
+
+ }
+ });
+ });
+ updateGLSLCacheSummary(); // Just updates the summary with the value when user opens the menu. Yes it's out of place.
+ updateMultiDrawSummary(); // Same as above
+ }
+
+ @Override
+ public void onSharedPreferenceChanged(SharedPreferences p, String s) {
+ GLSLCachePreference = findPreference("mg_renderer_setting_glsl_cache_size");
+ updateGLSLCacheSummary();
+ updateMultiDrawSummary();
+ }
+
+ private void updateMultiDrawSummary() {
+ if (MultiDrawEmulationPreference != null) {
+ if (MultiDrawEmulationPreference.getSummaryProvider() != null) {
+ MultiDrawSummaryProvider = MultiDrawEmulationPreference.getSummaryProvider();
+ }
+ if (ComputeMultiDrawPreference.isChecked()) {
+ MultiDrawEmulationPreference.setEnabled(false);
+ MultiDrawEmulationPreference.setSummaryProvider(null);
+ MultiDrawEmulationPreference.setSummary("(Experimental) Compute");
+ } else if (MultiDrawEmulationPreference != null) {
+ MultiDrawEmulationPreference.setEnabled(true);
+ MultiDrawEmulationPreference.setSummaryProvider(MultiDrawSummaryProvider);
+ }
+ }
+ }
+
+ private void updateGLSLCacheSummary() {
+ try {
+ if (Objects.equals(Objects.requireNonNull(this.GLSLCachePreference).getText(), "") || Integer.parseInt(Objects.requireNonNull(this.GLSLCachePreference.getText())) == 0) {
+ this.GLSLCachePreference.setSummary(getString(R.string.global_off));
+ } else this.GLSLCachePreference.setSummary(this.GLSLCachePreference.getText() + " MB");
+ } catch (Exception e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java
index 14f412e513..c17bfd4171 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceVideoFragment.java
@@ -45,12 +45,6 @@ public void onCreatePreferences(Bundle b, String str) {
requirePreference("alternate_surface", SwitchPreferenceCompat.class).setChecked(LauncherPreferences.PREF_USE_ALTERNATE_SURFACE);
requirePreference("force_vsync", SwitchPreferenceCompat.class).setChecked(LauncherPreferences.PREF_FORCE_VSYNC);
- ListPreference rendererListPreference = requirePreference("renderer",
- ListPreference.class);
- Tools.RenderersList renderersList = Tools.getCompatibleRenderers(getContext());
- rendererListPreference.setEntries(renderersList.rendererDisplayNames);
- rendererListPreference.setEntryValues(renderersList.rendererIds.toArray(new String[0]));
-
computeVisibility();
}
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 70a01ae017..01293980b3 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
@@ -93,6 +93,11 @@ public void setViewProfile(View v, String nm, boolean displaySelection) {
MinecraftProfile minecraftProfile = mProfiles.get(nm);
if(minecraftProfile == null) minecraftProfile = dummy;
Drawable cachedIcon = ProfileIconCache.fetchIcon(v.getResources(), nm, minecraftProfile.icon);
+ // Explicitly set bounds to match ExtendedTextView's drawableStartSize (_36sdp).
+ // Without this, BitmapDrawable uses raw pixel dimensions causing size inconsistency
+ // after profile edits (when the icon cache is dropped and re-fetched).
+ int iconSize = (int) v.getResources().getDimension(R.dimen._36sdp);
+ cachedIcon.setBounds(0, 0, iconSize, iconSize);
extendedTextView.setCompoundDrawablesRelative(cachedIcon, null, extendedTextView.getCompoundsDrawables()[2], null);
// Historically, the profile name "New" was hardcoded as the default profile name
@@ -147,4 +152,4 @@ public void reloadProfiles(ProfileAdapterExtra[] extraEntries) {
else mExtraEntires = extraEntries;
this.reloadProfiles();
}
-}
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java
new file mode 100644
index 0000000000..52a3a9ab4b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceProvider.java
@@ -0,0 +1,16 @@
+package net.kdt.pojavlaunch.render;
+
+import android.content.Context;
+import android.view.Surface;
+import android.view.View;
+
+public interface SurfaceProvider{
+ View create(Context context, SurfaceCallback callback);
+ void updateSize();
+
+ interface SurfaceCallback {
+ void onSurfaceAvailable(Surface surface);
+ void onSurfaceResized();
+ void onSurfaceDestroyed();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java
new file mode 100644
index 0000000000..4a1acd0bf2
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/SurfaceViewSurfaceProvider.java
@@ -0,0 +1,54 @@
+package net.kdt.pojavlaunch.render;
+
+import static net.kdt.pojavlaunch.CallbackBridge.windowHeight;
+import static net.kdt.pojavlaunch.CallbackBridge.windowWidth;
+
+import android.content.Context;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.CallbackBridge;
+
+public class SurfaceViewSurfaceProvider implements SurfaceProvider {
+ private SurfaceView mSurfaceView;
+ @Override
+ public View create(Context context, SurfaceCallback callback) {
+ mSurfaceView = new SurfaceView(context);
+ mSurfaceView.getHolder().addCallback(new CallbackAdapter(callback));
+ if(windowWidth != 0 && windowHeight != 0)
+ mSurfaceView.getHolder().setFixedSize(windowWidth, windowHeight);
+ return mSurfaceView;
+ }
+
+ @Override
+ public void updateSize() {
+ mSurfaceView.getHolder().setFixedSize(windowWidth, windowHeight);
+ }
+
+ private static class CallbackAdapter implements SurfaceHolder.Callback {
+ private final SurfaceCallback mCallback;
+
+ private CallbackAdapter(SurfaceCallback mCallback) {
+ this.mCallback = mCallback;
+ }
+
+ @Override
+ public void surfaceChanged(@NonNull SurfaceHolder surfaceHolder, int fmt, int width, int height) {
+ mCallback.onSurfaceResized();
+ }
+
+ @Override
+ public void surfaceCreated(@NonNull SurfaceHolder surfaceHolder) {
+ mCallback.onSurfaceAvailable(surfaceHolder.getSurface());
+ }
+
+ @Override
+ public void surfaceDestroyed(@NonNull SurfaceHolder surfaceHolder) {
+ mCallback.onSurfaceDestroyed();
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java
new file mode 100644
index 0000000000..99173b15f9
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/render/TextureViewSurfaceProvider.java
@@ -0,0 +1,64 @@
+package net.kdt.pojavlaunch.render;
+
+import static net.kdt.pojavlaunch.CallbackBridge.windowHeight;
+import static net.kdt.pojavlaunch.CallbackBridge.windowWidth;
+
+import android.content.Context;
+import android.graphics.SurfaceTexture;
+import android.view.Surface;
+import android.view.TextureView;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.Tools;
+
+public class TextureViewSurfaceProvider implements SurfaceProvider {
+ private TextureView mTextureView;
+ private SurfaceCallback mCallback;
+
+ @Override
+ public View create(Context context, SurfaceCallback callback) {
+ mCallback = callback;
+ mTextureView = new TextureView(context);
+ mTextureView.setOpaque(true);
+ mTextureView.setAlpha(1.0f);
+ mTextureView.setSurfaceTextureListener(new CallbackAdapter());
+ return mTextureView;
+ }
+
+ @Override
+ public void updateSize() {
+ SurfaceTexture surfaceTexture = mTextureView.getSurfaceTexture();
+ if(surfaceTexture != null) {
+ surfaceTexture.setDefaultBufferSize(windowWidth, windowHeight);
+ Tools.runOnUiThread(()->mCallback.onSurfaceResized());
+ }
+ }
+
+ private class CallbackAdapter implements TextureView.SurfaceTextureListener {
+
+ @Override
+ public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int i, int i1) {
+ if(windowWidth != 0 && windowHeight != 0)
+ surfaceTexture.setDefaultBufferSize(windowWidth, windowHeight);
+ mCallback.onSurfaceAvailable(new Surface(surfaceTexture));
+ }
+
+ @Override
+ public boolean onSurfaceTextureDestroyed(@NonNull SurfaceTexture surfaceTexture) {
+ mCallback.onSurfaceDestroyed();
+ return true;
+ }
+
+ @Override
+ public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture, int i, int i1) {
+ mCallback.onSurfaceResized();
+ }
+
+ @Override
+ public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) {
+
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java
index e2cb008277..37ab66c42a 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncAssetManager.java
@@ -2,6 +2,8 @@
import static net.kdt.pojavlaunch.Architecture.archAsString;
+import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid;
+import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture;
import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
import android.content.Context;
@@ -10,6 +12,7 @@
import com.kdt.mcgui.ProgressLayout;
+import net.kdt.pojavlaunch.Architecture;
import net.kdt.pojavlaunch.Tools;
import net.kdt.pojavlaunch.multirt.MultiRTUtils;
@@ -83,16 +86,52 @@ public static void unpackComponents(Context ctx){
unpackComponent(ctx, "caciocavallo17", false);
// Since the Java module system doesn't allow multiple JARs to declare the same module,
// we repack them to a single file here
- unpackComponent(ctx, "lwjgl3", false);
+ unpackLwjglNatives(ctx);
+ unpackComponent(ctx, "lwjgl3/3.3.3", false);
+ unpackComponent(ctx, "lwjgl3/3.4.1", false);
unpackComponent(ctx, "security", true);
unpackComponent(ctx, "arc_dns_injector", true);
+ unpackComponent(ctx, "methods_injector_agent", true);
unpackComponent(ctx, "forge_installer", true);
} catch (IOException e) {
- Log.e("AsyncAssetManager", "Failed o unpack components !",e );
+ Log.e("AsyncAssetManager", "Failed to unpack components !",e );
}
ProgressLayout.clearProgress(ProgressLayout.EXTRACT_COMPONENTS);
});
}
+ // Piggybacks off of the java modules extracting later to use their version files for update checks
+ // This is indeed prone to breaking.
+ private static void unpackLwjglNatives(Context ctx) throws IOException {
+ AssetManager am = ctx.getAssets();
+ String rootDir = Tools.DIR_DATA;
+ String sArch = archAsStringAndroid(getDeviceArchitecture());
+
+ String[] lwjglVersions = {"3.3.3", "3.4.1"};
+ for (String lwjglVer : lwjglVersions) {
+ File versionFile = new File(Tools.DIR_GAME_HOME + String.format("/lwjgl3/%s/version", lwjglVer));
+ InputStream is = am.open("components/lwjgl3/" + lwjglVer + "/version");
+ String pathToLwjglNatives = String.format("lwjgl-%s-natives/", lwjglVer) + sArch;
+
+ boolean shouldUpdate = true;
+ if (versionFile.exists()) {
+ FileInputStream fis = new FileInputStream(versionFile);
+ String release1 = Tools.read(is);
+ String release2 = Tools.read(fis);
+ if (release1.equals(release2))
+ shouldUpdate = false;
+ }
+
+ if (shouldUpdate) {
+ Log.i("UnpackLwjgl", lwjglVer + " was installed manually, or does not exist, unpacking new...");
+ String[] fileList = am.list("components/" + pathToLwjglNatives);
+ for (String fileName : fileList) {
+ Tools.copyAssetFile(ctx, "components/" + pathToLwjglNatives + "/" + fileName, rootDir + "/" + pathToLwjglNatives, true);
+ }
+ } else {
+ Log.i("UnpackLwjgl", lwjglVer + " is up-to-date with the launcher, continuing...");
+ }
+ }
+ }
private static void unpackComponent(Context ctx, String component, boolean privateDirectory) throws IOException {
AssetManager am = ctx.getAssets();
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java
index 0a18ac0fbb..3238a4ffdd 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java
@@ -26,7 +26,7 @@ public class AsyncVersionList {
public void getVersionList(@Nullable VersionDoneListener listener, boolean secondPass){
sExecutorService.execute(() -> {
- File versionFile = new File(Tools.DIR_DATA + "/version_list.json");
+ File versionFile = new File(Tools.DIR_CACHE + "/version_list.json");
JMinecraftVersionList versionList = null;
try{
if(!versionFile.exists() || (System.currentTimeMillis() > versionFile.lastModified() + 86400000 )){
@@ -68,7 +68,7 @@ private JMinecraftVersionList downloadVersionList(String mirror){
// Then save the version list
//TODO make it not save at times ?
- FileOutputStream fos = new FileOutputStream(Tools.DIR_DATA + "/version_list.json");
+ FileOutputStream fos = new FileOutputStream(Tools.DIR_CACHE + "/version_list.json");
fos.write(jsonString.getBytes());
fos.close();
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java
index 1c7bc7fbee..83471c833e 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/MinecraftDownloader.java
@@ -3,6 +3,9 @@
import static net.kdt.pojavlaunch.PojavApplication.sExecutorService;
import android.app.Activity;
+import android.content.Context;
+import android.net.ConnectivityManager;
+import android.net.NetworkInfo;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -25,7 +28,11 @@
import net.kdt.pojavlaunch.value.MinecraftClientInfo;
import net.kdt.pojavlaunch.value.MinecraftLibraryArtifact;
+import java.io.BufferedReader;
import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileReader;
+import java.io.FileWriter;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Map;
@@ -54,6 +61,9 @@ public class MinecraftDownloader {
private static final ThreadLocal sThreadLocalDownloadBuffer = new ThreadLocal<>();
+ private boolean isLocalProfile = false;
+ private boolean isOnline;
+
/**
* Start the game version download process on the global executor service.
* @param activity Activity, used for automatic installation of JRE 17 if needed
@@ -62,12 +72,46 @@ public class MinecraftDownloader {
* @param listener The download status listener
*/
public void start(@Nullable Activity activity, @Nullable JMinecraftVersionList.Version version,
- @NonNull String realVersion, // this was there for a reason
+ @NonNull String realVersion,
@NonNull AsyncMinecraftDownloader.DoneListener listener) {
+ if(activity != null){
+ isLocalProfile = Tools.isLocalProfile(activity);
+ isOnline = Tools.isOnline(activity);
+ Tools.switchDemo(Tools.isDemoProfile(activity));
+
+ } else {
+ isLocalProfile = true;
+ Tools.switchDemo(true);
+ }
+
sExecutorService.execute(() -> {
try {
+ if(isLocalProfile || !isOnline) {
+ String versionMessage = realVersion; // Use provided version unless we find its a modded instance
+
+ // See if provided version is a modded version and if that version depends on another jar, check for presence of both jar's .json.
+ try {
+ // This reads the .json associated with the provided version. If it fails, we can assume it's not installed.
+ File providedJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + realVersion + "/" + realVersion + ".json");
+ JMinecraftVersionList.Version providedJson = Tools.GLOBAL_GSON.fromJson(Tools.read(providedJsonFile.getAbsolutePath()), JMinecraftVersionList.Version.class);
+
+ // This checks if running modded version that depends on other jars, so we use that for the error message.
+ File vanillaJsonFile = new File(Tools.DIR_HOME_VERSION + "/" + providedJson.inheritsFrom + "/" + providedJson.inheritsFrom + ".json");
+ versionMessage = providedJson.inheritsFrom != null ? providedJson.inheritsFrom : versionMessage;
+
+ // Ensure they're both not some 0 byte corrupted json
+ if (providedJsonFile.length() == 0 || vanillaJsonFile.exists() && vanillaJsonFile.length() == 0){
+ throw new RuntimeException("Minecraft "+versionMessage+ " is needed by " +realVersion); }
+
+ listener.onDownloadDone();
+ } catch (Exception e) {
+ String tryagain = !isOnline ? "Please ensure you have an internet connection" : "Please try again on your Microsoft Account";
+ Tools.showErrorRemote(versionMessage + " is not currently installed. "+ tryagain, e);
+ }
+ }else {
downloadGame(activity, version, realVersion);
listener.onDownloadDone();
+ }
}catch (Exception e) {
listener.onDownloadFailed(e);
}
@@ -97,9 +141,7 @@ private void downloadGame(Activity activity, JMinecraftVersionList.Version verIn
mDownloaderThreadException = new AtomicReference<>(null);
mUseFileCounter = false;
- if(!downloadAndProcessMetadata(activity, verInfo, versionName)) {
- throw new RuntimeException(activity.getString(R.string.exception_failed_to_unpack_jre17));
- }
+ downloadAndProcessMetadata(activity, verInfo, versionName);
ArrayBlockingQueue taskQueue =
new ArrayBlockingQueue<>(mScheduledDownloadTasks.size(), false);
@@ -249,7 +291,7 @@ private boolean downloadAndProcessMetadata(Activity activity, JMinecraftVersionL
}
if(activity != null && !NewJREUtil.installNewJreIfNeeded(activity, verInfo)){
- return false;
+ throw new RuntimeException(activity.getString(R.string.exception_failed_to_unpack_jre17));
}
JAssets assets = downloadAssetsIndex(verInfo);
@@ -456,18 +498,49 @@ private String downloadSha1() throws IOException {
* Since Minecraft libraries are stored in maven repositories, try to use
* this when downloading libraries without hashes in the json.
*/
- private void tryGetLibrarySha1() {
+ private void tryGetLibrarySha1() throws IOException {
+ File sha1CacheDir = new File(Tools.DIR_CACHE + "/sha1hashes");
+ File cacheFile = new File(sha1CacheDir.getAbsolutePath() + FileUtils.getFileName(mTargetUrl) + ".sha");
+
+ // Only use cache when its offline. No point in having cache invalidation now!
+ if (!isOnline || !LauncherPreferences.PREF_CHECK_LIBRARY_SHA) { // Well not only offlines..this setting speeds up launch times at least!
+ try (BufferedReader cacheFileReader = new BufferedReader(new FileReader(cacheFile))) {
+ mTargetSha1 = cacheFileReader.readLine();
+ if (mTargetSha1 != null) {
+ Log.i("MinecraftDownloader", "Reading Hash from cache: " + mTargetSha1 + " from " + cacheFile);
+ } else if (cacheFile.exists()) {
+ Log.i("MinecraftDownloader", "Deleting invalid hash from cache: " + cacheFile);
+ cacheFile.delete();
+ }
+ } catch (FileNotFoundException ignored) {
+ mTargetSha1 = null;
+ Log.w("MinecraftDownloader", "Failed to read hash for " + cacheFile);
+ }
+ return;
+ }
+
String resultHash = null;
try {
resultHash = downloadSha1();
// The hash is a 40-byte download.
mInternetUsageCounter.getAndAdd(40);
- }catch (IOException e) {
+ } catch (IOException e) {
Log.i("MinecraftDownloader", "Failed to download hash", e);
+ if (cacheFile.exists() && new BufferedReader(new FileReader(cacheFile)).readLine() == null) {
+ Log.i("MinecraftDownloader", "Deleting failed hash download from cache: " + cacheFile);
+ cacheFile.delete();
+ }
}
- if(resultHash != null) {
- Log.i("MinecraftDownloader", "Got hash: "+resultHash+ " for "+FileUtils.getFileName(mTargetUrl));
+ if (resultHash != null) {
+ Log.i("MinecraftDownloader", "Got hash: " + resultHash + " for " + FileUtils.getFileName(mTargetUrl));
mTargetSha1 = resultHash;
+ if (!sha1CacheDir.exists()) {
+ sha1CacheDir.mkdir(); // If mkdir() fails, something went wrong with initializing /data/data/. mkdirs() isn't used on purpose
+ }
+ try (FileWriter writeHash = new FileWriter(cacheFile)) {
+ Log.i("MinecraftDownloader", "Saving hash: " + resultHash + " for " + FileUtils.getFileName(mTargetUrl) + " to " + cacheFile);
+ writeHash.write(resultHash);
+ }
}
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java
new file mode 100644
index 0000000000..6f469941a8
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/theme/ThemeManager.java
@@ -0,0 +1,132 @@
+package net.kdt.pojavlaunch.theme;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.StyleRes;
+import androidx.palette.graphics.Palette;
+
+import net.kdt.pojavlaunch.R;
+import net.kdt.pojavlaunch.fragments.RightPaneHomeFragment;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+
+import java.io.File;
+
+public class ThemeManager {
+
+ private static final String KEY_THEME = "launcher_theme";
+ public static final String KEY_GRADIENT = "enable_bg_gradient";
+
+ public static final Preset[] PRESETS = {
+ new Preset("Default (Copper)", R.style.AppTheme, R.style.AppTheme_Gradient),
+ new Preset("Midnight Blue", R.style.AppTheme_MidnightBlue, R.style.AppTheme_MidnightBlue_Gradient),
+ new Preset("Forest Green", R.style.AppTheme_ForestGreen, R.style.AppTheme_ForestGreen_Gradient),
+ new Preset("Crimson", R.style.AppTheme_Crimson, R.style.AppTheme_Crimson_Gradient),
+ new Preset("Amethyst", R.style.AppTheme_Amethyst, R.style.AppTheme_Amethyst_Gradient),
+ new Preset("Arctic", R.style.AppTheme_Arctic, R.style.AppTheme_Arctic_Gradient),
+ };
+
+ public static void applyPreset(@NonNull Preset preset) {
+ LauncherPreferences.DEFAULT_PREF.edit()
+ .putInt(KEY_THEME, preset.styleRes)
+ .apply();
+ }
+
+ public static void resetToDefault() {
+ applyPreset(PRESETS[0]);
+ }
+
+ /**
+ * Apply the current theme's bgMainDrawable to a preference fragment's root view.
+ * Called from LauncherPreferenceFragment.onViewCreated().
+ */
+ public static void applyToPrefView(@NonNull android.view.View view) {
+ android.util.TypedValue tv = new android.util.TypedValue();
+ view.getContext().getTheme().resolveAttribute(
+ net.kdt.pojavlaunch.R.attr.bgMainDrawable, tv, true);
+ if (tv.type >= android.util.TypedValue.TYPE_FIRST_COLOR_INT
+ && tv.type <= android.util.TypedValue.TYPE_LAST_COLOR_INT) {
+ view.setBackgroundColor(tv.data);
+ } else if (tv.resourceId != 0) {
+ view.setBackgroundResource(tv.resourceId);
+ }
+ }
+
+ /**
+ * Call in Activity.onCreate() BEFORE setContentView().
+ * Returns the flat or gradient style depending on the gradient toggle.
+ */
+ @StyleRes
+ public static int getSavedTheme() {
+ int base = LauncherPreferences.DEFAULT_PREF.getInt(KEY_THEME, R.style.AppTheme);
+ boolean gradient = LauncherPreferences.DEFAULT_PREF.getBoolean(KEY_GRADIENT, false);
+
+ // Normalise: always find the matching flat preset style
+ // (guards against an old gradient style ID being stored in prefs)
+ Preset matched = PRESETS[0];
+ for (Preset p : PRESETS) {
+ if (p.styleRes == base || p.gradientStyleRes == base) {
+ matched = p;
+ break;
+ }
+ }
+
+ return gradient ? matched.gradientStyleRes : matched.styleRes;
+ }
+
+ /**
+ * Use Palette API to pick the closest built-in preset from the custom background.
+ */
+ public static boolean applyFromCustomBackground() {
+ File bgFile = new File(RightPaneHomeFragment.CUSTOM_BG_PATH);
+ if (!bgFile.exists()) return false;
+
+ BitmapFactory.Options opts = new BitmapFactory.Options();
+ opts.inSampleSize = 4;
+ Bitmap bmp = BitmapFactory.decodeFile(bgFile.getAbsolutePath(), opts);
+ if (bmp == null) return false;
+
+ Palette palette = Palette.from(bmp).maximumColorCount(24).generate();
+ bmp.recycle();
+
+ Palette.Swatch dominant = firstNonNull(
+ palette.getDarkVibrantSwatch(),
+ palette.getVibrantSwatch(),
+ palette.getDarkMutedSwatch(),
+ palette.getMutedSwatch()
+ );
+ if (dominant == null) return false;
+
+ float[] hsl = dominant.getHsl();
+ float[] presetHues = { 20f, 210f, 120f, 0f, 280f, 185f };
+ Preset best = PRESETS[0];
+ float bestDist = Float.MAX_VALUE;
+ for (int i = 0; i < PRESETS.length; i++) {
+ float dist = Math.abs(hsl[0] - presetHues[i]);
+ if (dist > 180) dist = 360 - dist;
+ if (dist < bestDist) { bestDist = dist; best = PRESETS[i]; }
+ }
+
+ applyPreset(best);
+ return true;
+ }
+
+ @SafeVarargs
+ private static T firstNonNull(T... items) {
+ for (T t : items) if (t != null) return t;
+ return null;
+ }
+
+ public static final class Preset {
+ public final String name;
+ public final int styleRes;
+ public final int gradientStyleRes;
+ public Preset(String name, int styleRes, int gradientStyleRes) {
+ this.name = name;
+ this.styleRes = styleRes;
+ this.gradientStyleRes = gradientStyleRes;
+ }
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java
index dbdbadaf07..0f3bc3e962 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java
@@ -15,6 +15,7 @@
@SuppressWarnings("IOStreamConstructor")
public class DownloadUtils {
public static final String USER_AGENT = Tools.APP_NAME;
+ private static final int TIME_OUT = 10000;
public static void download(String url, OutputStream os) throws IOException {
download(new URL(url), os);
@@ -26,7 +27,8 @@ public static void download(URL url, OutputStream os) throws IOException {
// System.out.println("Connecting: " + url.toString());
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestProperty("User-Agent", USER_AGENT);
- conn.setConnectTimeout(10000);
+ conn.setConnectTimeout(TIME_OUT);
+ conn.setReadTimeout(TIME_OUT);
conn.setDoInput(true);
conn.connect();
if (conn.getResponseCode() != HttpURLConnection.HTTP_OK) {
@@ -59,6 +61,12 @@ public static void downloadFile(String url, File out) throws IOException {
FileUtils.ensureParentDirectory(out);
try (FileOutputStream fileOutputStream = new FileOutputStream(out)) {
download(url, fileOutputStream);
+ } catch (IOException e) {
+ if (out.length() < 1) { // Only delete it if file is 0 bytes cause this file might already be downloaded and something else went wrong.
+ Log.i("DownloadUtils", "Cleaning up failed download: " + out.getAbsolutePath());
+ out.delete();
+ throw e;
+ }
}
}
@@ -67,6 +75,8 @@ public static void downloadFileMonitored(String urlInput, File outputFile, @Null
FileUtils.ensureParentDirectory(outputFile);
HttpURLConnection conn = (HttpURLConnection) new URL(urlInput).openConnection();
+ conn.setConnectTimeout(TIME_OUT);
+ conn.setReadTimeout(TIME_OUT);
InputStream readStr = conn.getInputStream();
try (FileOutputStream fos = new FileOutputStream(outputFile)) {
int current;
@@ -81,8 +91,9 @@ public static void downloadFileMonitored(String urlInput, File outputFile, @Null
monitor.updateProgress(overall, length);
}
conn.disconnect();
+ } catch (IOException e) {
+ throw new IOException("Unable to download from " + urlInput, e);
}
-
}
public static T downloadStringCached(String url, String cacheName, ParseCallback parseCallback) throws IOException, ParseException{
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java
new file mode 100644
index 0000000000..5868948b46
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/GameOptionsUtils.java
@@ -0,0 +1,69 @@
+package net.kdt.pojavlaunch.utils;
+
+import android.util.Log;
+
+public class GameOptionsUtils {
+ /**
+ * Parse an integer. If the input value is null or not a valid integer, return the default value.
+ * @param value the String to parse
+ * @param defaultValue the default value
+ * @return the parsed value or default
+ */
+ public static int parseIntDefault(String value, int defaultValue) {
+ if(value == null) return defaultValue;
+ try {
+ return Integer.parseInt(value);
+ }catch (NumberFormatException e) {
+ return defaultValue;
+ }
+ }
+
+ /**
+ * Decrease cloud rendering distance in order to avoid the Mali cloud rendering slowdown bug
+ */
+ private static void fixDeathCloud() {
+ GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo();
+ if(!info.isArm()) return; // Not an affected GPU
+ int cloudRange = parseIntDefault(MCOptionUtils.get("cloudRange"), 128);
+ if(cloudRange <= 64) return; // Not affected below 117 (but let's err on the safe side)
+ MCOptionUtils.set("cloudRange", "64");
+ }
+
+ /**
+ * Disable the Narrator. Clicking on the button, even though it says "Not Supported", turns it
+ * on and causes MC to generate insanely large log files when starting again
+ */
+ private static void disableNarrator() {
+ if(parseIntDefault(MCOptionUtils.get("narrator"), 0) == 0) return;
+ MCOptionUtils.set("narrator", "0");
+ }
+
+ /**
+ * Disable fullscreen. The launcher runs always in fullscreen anyway, and this
+ * helps with some mods that can't tolerate an empty video mode list
+ */
+ private static void disableFullscreen() {
+ String fullscreen = MCOptionUtils.get("fullscreen");
+ if(fullscreen == null) return;
+ if(fullscreen.equals("true")) MCOptionUtils.set("fullscreen", "false");
+ else if(fullscreen.equals("1")) MCOptionUtils.set("fullscreen","0");
+ }
+
+ public static void fixOptions(boolean isLtw) {
+ try {
+ MCOptionUtils.load();
+ }catch (Exception e) {
+ Log.e("Tools", "Failed to load config", e);
+ }
+
+ if(isLtw) fixDeathCloud();
+ disableFullscreen();
+ disableNarrator();
+
+ try {
+ MCOptionUtils.save();
+ }catch (Exception e) {
+ Log.e("Tools", "Failed to save config", e);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java
new file mode 100644
index 0000000000..a934238317
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/HashUtils.java
@@ -0,0 +1,67 @@
+package net.kdt.pojavlaunch.utils;
+
+import static android.os.Build.VERSION.SDK_INT;
+
+import android.os.Build;
+
+import androidx.annotation.RequiresApi;
+
+import org.apache.commons.codec.DecoderException;
+import org.apache.commons.codec.binary.Hex;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.channels.SeekableByteChannel;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.util.Arrays;
+
+public class HashUtils {
+ @RequiresApi(26)
+ private static byte[] fileHashNio(MessageDigest messageDigest, Path p) throws IOException {
+ ByteBuffer buffer = ByteBuffer.allocateDirect(65535);
+ try(SeekableByteChannel channel = Files.newByteChannel(p, StandardOpenOption.READ)) {
+ while(true) {
+ buffer.rewind();
+ if(channel.read(buffer) == -1) break;
+ buffer.flip();
+ messageDigest.update(buffer);
+ }
+ }
+ return messageDigest.digest();
+ }
+
+ private static byte[] fileHashLegacy(MessageDigest messageDigest, File f) throws IOException {
+ byte[] sha1Buffer = new byte[65535];
+ try (FileInputStream stream = new FileInputStream(f)){
+ int readLen;
+ while((readLen = stream.read(sha1Buffer)) != -1) {
+ messageDigest.update(sha1Buffer, 0, readLen);
+ }
+ }
+ return messageDigest.digest();
+ }
+
+ public static byte[] fileHash(MessageDigest messageDigest, File f) throws IOException {
+ if (SDK_INT >= Build.VERSION_CODES.O) return fileHashNio(messageDigest, f.toPath());
+ else return fileHashLegacy(messageDigest, f);
+ }
+
+ public static boolean compareSHA1(File f, String sourceSHA) throws IOException{
+ try {
+ MessageDigest messageDigest = MessageDigest.getInstance("SHA-1");
+ byte[] wantedBytes = Hex.decodeHex(sourceSHA.toCharArray());
+ byte[] localFileBytes = fileHash(messageDigest, f);
+ return Arrays.equals(localFileBytes, wantedBytes);
+ }catch (NoSuchAlgorithmException e) {
+ throw new RuntimeException("WTF? SHA-1 digest missing!", e);
+ }catch (DecoderException e) {
+ throw new IOException("Bad SHA-1 hash: "+sourceSHA+" for file "+f.getName());
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java
index 166ec8ac31..1b0eb01f66 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/JREUtils.java
@@ -1,6 +1,8 @@
package net.kdt.pojavlaunch.utils;
import static net.kdt.pojavlaunch.Architecture.ARCH_X86;
+import static net.kdt.pojavlaunch.Architecture.archAsStringAndroid;
+import static net.kdt.pojavlaunch.Architecture.getDeviceArchitecture;
import static net.kdt.pojavlaunch.Architecture.is64BitsDevice;
import static net.kdt.pojavlaunch.Tools.LOCAL_RENDERER;
import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR;
@@ -30,6 +32,7 @@
import net.kdt.pojavlaunch.multirt.Runtime;
import net.kdt.pojavlaunch.plugins.FFmpegPlugin;
import net.kdt.pojavlaunch.prefs.*;
+
import org.lwjgl.glfw.*;
public class JREUtils {
@@ -166,6 +169,8 @@ public static void relocateLibPath(Runtime runtime, String jreHome) {
.append("/vendor/").append(libName).append(":")
.append("/vendor/").append(libName).append("/hw:")
.append(NATIVE_LIB_DIR);
+ // FIXME: Freetype is shipped inside lwjgl. We should ship it outside and use lwjgl native jars instead.
+ ldLibraryPath.append(String.format(":%s/lwjgl-3.3.3-natives/%s", Tools.DIR_DATA, archAsStringAndroid(getDeviceArchitecture())));
LD_LIBRARY_PATH = ldLibraryPath.toString();
}
@@ -213,28 +218,32 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws
}
if(LOCAL_RENDERER != null) {
- envMap.put("POJAV_RENDERER", LOCAL_RENDERER);
+ envMap.put("AMETHYST_RENDERER", LOCAL_RENDERER);
if(LOCAL_RENDERER.equals("opengles3_ltw")) {
envMap.put("LIBGL_ES", "3");
envMap.put("POJAVEXEC_EGL","libltw.so"); // Use ANGLE EGL
}
+ if(LOCAL_RENDERER.equals("opengles_mobileglues")){
+ envMap.put("MG_DIR_PATH", Tools.DIR_DATA + "/MobileGlues");
+ envMap.put("POJAVEXEC_EGL","libmobileglues.so");
+ }
+ if(LOCAL_RENDERER.equals("opengles2")){
+ envMap.put("LIBGL_ES", "2"); // Krypton Wrapper crashes with 1
+ }
+ if (LOCAL_RENDERER.equals("opengles3_desktopgl_zink_kopper")){
+ envMap.put("POJAVEXEC_EGL","libEGL_mesa.so"); // Use Mesa EGL
+ if (Tools.shouldUseUBWC()) envMap.put("FD_DEV_FEATURES", "enable_tp_ubwc_flag_hint=1"); // Turnip fix for OneUI rendering issues
+ }
+ if (LOCAL_RENDERER.toLowerCase().contains("zink")){
+ // This is sketch but it fixes a lot of things, if it causes problems we can just undo it.
+ envMap.put("MESA_GL_VERSION_OVERRIDE","4.6COMPAT");
+ envMap.put("MESA_GLSL_VERSION_OVERRIDE","460");
+ }
}
if(LauncherPreferences.PREF_BIG_CORE_AFFINITY) envMap.put("POJAV_BIG_CORE_AFFINITY", "1");
envMap.put("AWTSTUB_WIDTH", Integer.toString(CallbackBridge.windowWidth > 0 ? CallbackBridge.windowWidth : CallbackBridge.physicalWidth));
envMap.put("AWTSTUB_HEIGHT", Integer.toString(CallbackBridge.windowHeight > 0 ? CallbackBridge.windowHeight : CallbackBridge.physicalHeight));
- File customEnvFile = new File(Tools.DIR_GAME_HOME, "custom_env.txt");
- if (customEnvFile.exists() && customEnvFile.isFile()) {
- BufferedReader reader = new BufferedReader(new FileReader(customEnvFile));
- String line;
- while ((line = reader.readLine()) != null) {
- // Not use split() as only split first one
- int index = line.indexOf("=");
- envMap.put(line.substring(0, index), line.substring(index + 1));
- }
- reader.close();
- }
-
GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo();
if(!envMap.containsKey("LIBGL_ES") && LOCAL_RENDERER != null) {
int glesMajor = info.glesMajorVersion;
@@ -256,6 +265,8 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws
envMap.put("POJAV_LOAD_TURNIP", "1");
}
+ readCustomEnv(envMap); // Must be last so it overrides anything the user sets for obvious reasons.
+
for (Map.Entry env : envMap.entrySet()) {
Logger.appendToLog("Added custom env: " + env.getKey() + "=" + env.getValue());
try {
@@ -274,6 +285,19 @@ public static void setJavaEnvironment(Activity activity, String jreHome) throws
// return ldLibraryPath;
}
+ private static void readCustomEnv(Map envMap) throws IOException {
+ File customEnvFile = new File(Tools.DIR_GAME_HOME, "custom_env.txt");
+ if (customEnvFile.exists() && customEnvFile.isFile()) {
+ BufferedReader reader = new BufferedReader(new FileReader(customEnvFile));
+ String line;
+ while ((line = reader.readLine()) != null) {
+ // Not use split() as only split first one
+ int index = line.indexOf("=");
+ envMap.put(line.substring(0, index), line.substring(index + 1));
+ }
+ reader.close();
+ }
+ }
public static void launchJavaVM(final AppCompatActivity activity, final Runtime runtime, File gameDirectory, final List JVMArgs, final String userArgsString) throws Throwable {
String runtimeHome = MultiRTUtils.getRuntimeHome(runtime.name).getAbsolutePath();
@@ -306,10 +330,17 @@ public static void launchJavaVM(final AppCompatActivity activity, final Runtime
// Force LWJGL to use the Freetype library intended for it, instead of using the one
// that we ship with Java (since it may be older than what's needed)
- userArgs.add("-Dorg.lwjgl.freetype.libname="+ NATIVE_LIB_DIR+"/libfreetype.so");
+ userArgs.add("-Dorg.lwjgl.freetype.libname="+ Tools.lwjglNativesDir +"/libfreetype.so");
+ // Our spirv-cross is compiled shared, so it gets named shared.
+ userArgs.add("-Dorg.lwjgl.spvc.libname=spirv-cross-c-shared");
+
+ // We don't have jemalloc for our LWJGL so set the allocator to system to avoid error logs
+ userArgs.add("-Dorg.lwjgl.system.allocator=system");
// Some phones are not using the right number of cores, fix that
userArgs.add("-XX:ActiveProcessorCount=" + java.lang.Runtime.getRuntime().availableProcessors());
+ // Adds/changes methods for compatibility
+ userArgs.add("-javaagent:"+new File(Tools.DIR_DATA,"methods_injector_agent/methods_injector_agent.jar").getAbsolutePath());
userArgs.addAll(JVMArgs);
activity.runOnUiThread(() -> Toast.makeText(activity, activity.getString(R.string.autoram_info_msg,LauncherPreferences.PREF_RAM_ALLOCATION), Toast.LENGTH_SHORT).show());
@@ -464,20 +495,23 @@ public static String loadGraphicsLibrary(){
case "opengles2":
case "opengles2_5":
case "opengles3":
- renderLibrary = "libgl4es_114.so"; break;
+ renderLibrary = "libgl4es_115.so"; break;
case "vulkan_zink": renderLibrary = "libOSMesa.so"; break;
+ case "opengles_mobileglues": renderLibrary = "libmobileglues.so"; break;
+ case "opengles3_desktopgl_zink": renderLibrary = "libglxshim.so"; break;
case "opengles3_ltw" : renderLibrary = "libltw.so"; break;
+ case "opengles3_KW" : renderLibrary = "libng_gl4es.so"; break;
default:
Log.w("RENDER_LIBRARY", "No renderer selected, defaulting to opengles2");
- renderLibrary = "libgl4es_114.so";
+ renderLibrary = "libgl4es_115.so";
break;
}
if (!dlopen(renderLibrary) && !dlopen(findInLdLibPath(renderLibrary))) {
Log.e("RENDER_LIBRARY","Failed to load renderer " + renderLibrary + ". Falling back to GL4ES 1.1.4");
LOCAL_RENDERER = "opengles2";
- renderLibrary = "libgl4es_114.so";
- dlopen(NATIVE_LIB_DIR + "/libgl4es_114.so");
+ renderLibrary = "libgl4es_115.so";
+ dlopen(NATIVE_LIB_DIR + "/libgl4es_115.so");
}
return renderLibrary;
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java
new file mode 100644
index 0000000000..4e6a35a9b1
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/MavenNameUtils.java
@@ -0,0 +1,34 @@
+package net.kdt.pojavlaunch.utils;
+
+public class MavenNameUtils {
+
+ public static String mavenBaseName(String libName) {
+ String[] libInfos = libName.split(":");
+ StringBuilder builder = new StringBuilder()
+ .append(libInfos[0]).append(':').append(libInfos[1]);
+ for(int i = 3; i < libInfos.length; i++) {
+ builder.append(':').append(libInfos[i]);
+ }
+ return builder.toString();
+ }
+
+ public static StringBuilder mavenNameToPathBuilder(String libName) {
+ String[] libInfos = libName.split(":");
+ return new StringBuilder()
+ .append(libInfos[0].replaceAll("\\.", "/"))
+ .append('/')
+ .append(libInfos[1])
+ .append('/')
+ .append(libInfos[2])
+ .append('/')
+ .append(libInfos[1]).append('-').append(libInfos[2]);
+ }
+
+ public static String mavenNameToAarPath(String libName) {
+ return mavenNameToPathBuilder(libName).append(".aar").toString();
+ }
+
+ public static String mavenNameToPath(String libName) {
+ return mavenNameToPathBuilder(libName).append(".jar").toString();
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java
new file mode 100644
index 0000000000..6a7d3cfb94
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/RendererCompatUtil.java
@@ -0,0 +1,80 @@
+package net.kdt.pojavlaunch.utils;
+
+import static android.os.Build.VERSION.SDK_INT;
+
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.os.Build;
+
+import net.kdt.pojavlaunch.Architecture;
+import net.kdt.pojavlaunch.Tools;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import git.artdeell.mojo.R;
+
+public class RendererCompatUtil {
+ private static RenderersList sCompatibleRenderers;
+
+ public static boolean checkVulkanSupport(PackageManager packageManager) {
+ if(SDK_INT >= Build.VERSION_CODES.N) {
+ return packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_LEVEL) &&
+ packageManager.hasSystemFeature(PackageManager.FEATURE_VULKAN_HARDWARE_VERSION);
+ }
+ return false;
+ }
+
+ /** Return the renderers that are compatible with this device */
+ public static RenderersList getCompatibleRenderers(Context context) {
+ if(sCompatibleRenderers != null) return sCompatibleRenderers;
+ Resources resources = context.getResources();
+ String[] defaultRenderers = resources.getStringArray(R.array.renderer_values);
+ String[] defaultRendererNames = resources.getStringArray(R.array.renderer);
+ boolean deviceHasVulkan = checkVulkanSupport(context.getPackageManager());
+ // Current Mesa requires API29+
+ boolean deviceCompatibleMesa = SDK_INT >= 29;
+ boolean deviceHasOpenGLES3 = JREUtils.getDetectedVersion() >= 3;
+ // LTW is an optional dependency
+ boolean appHasLtw = new File(Tools.NATIVE_LIB_DIR, "libltw.so").exists();
+ List rendererIds = new ArrayList<>(defaultRenderers.length);
+ List rendererNames = new ArrayList<>(defaultRendererNames.length);
+ for(int i = 0; i < defaultRenderers.length; i++) {
+ String rendererId = defaultRenderers[i];
+ if(rendererId.contains("vulkan") && !deviceHasVulkan) continue;
+ if(rendererId.contains("zink") && !deviceCompatibleMesa) continue;
+ // freedreno is available only on Adreno GPUs
+ if(rendererId.contains("freedreno") && (!(GLInfoUtils.getGlInfo().isAdreno()) || !deviceCompatibleMesa)) continue;
+ if(rendererId.contains("ltw") && (!deviceHasOpenGLES3 || !appHasLtw)) continue;
+ rendererIds.add(rendererId);
+ rendererNames.add(defaultRendererNames[i]);
+ }
+ sCompatibleRenderers = new RenderersList(rendererIds,
+ rendererNames.toArray(new String[0]));
+
+ return sCompatibleRenderers;
+ }
+
+ /** Checks if the renderer Id is compatible with the current device */
+ public static boolean checkRendererCompatible(Context context, String rendererName) {
+ return getCompatibleRenderers(context).rendererIds.contains(rendererName);
+ }
+
+ /** Releases the cache of compatible renderers. */
+ public static void releaseRenderersCache() {
+ sCompatibleRenderers = null;
+ System.gc();
+ }
+
+ public static class RenderersList {
+ public final List rendererIds;
+ public final String[] rendererDisplayNames;
+
+ public RenderersList(List rendererIds, String[] rendererDisplayNames) {
+ this.rendererIds = rendererIds;
+ this.rendererDisplayNames = rendererDisplayNames;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java
new file mode 100644
index 0000000000..97cbd80361
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/SignatureCheckUtil.java
@@ -0,0 +1,97 @@
+package net.kdt.pojavlaunch.utils;
+
+import android.content.res.AssetManager;
+import android.util.ArrayMap;
+import android.util.Base64;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.security.InvalidKeyException;
+import java.security.NoSuchAlgorithmException;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.SignatureException;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.util.Map;
+
+public class SignatureCheckUtil {
+ private final PublicKey mPublicKey;
+
+ public SignatureCheckUtil(PublicKey mPublicKey) {
+ this.mPublicKey = mPublicKey;
+ }
+
+ /**
+ * Decode a bundle of signatures. A bundle of signatures has the following format:
+ * fileName1:base64-rsa4096-signature
+ * fileName2:base64-rsa4096-signature
+ * Invalid signatures aren't included in the resulting Map.
+ * @param bundle the original string of the bundle
+ * @return each decoded signature mapped to each file name
+ */
+ public static Map decodeSignatureBundle(String bundle) {
+ String[] signatureLines = bundle.split("\n");
+ ArrayMap signatures = new ArrayMap<>(signatureLines.length);
+ for(String signatureLine : signatureLines) {
+ String[] splitSignLine = signatureLine.split(":");
+ if(splitSignLine.length != 2) continue;
+ try {
+ byte[] signatureBytes = decodeRsa4096FromBase64(splitSignLine[1]);
+ if(signatureBytes == null) continue;
+ signatures.put(splitSignLine[0], signatureBytes);
+ }catch (IllegalArgumentException ignored) {}
+ }
+ return signatures;
+ }
+
+ /**
+ * Decode an RSA4096-encrypted signature from a Base64 string
+ * @param base64 the original base64 data
+ * @return the decoded bytes, or null if the data length isn't correct
+ */
+ public static byte[] decodeRsa4096FromBase64(String base64) {
+ byte[] rsaBytes = Base64.decode(base64, Base64.DEFAULT);
+ if(rsaBytes.length != 512) return null;
+ return rsaBytes;
+ }
+
+ /**
+ * Verifies the signature of an input stream against the cert.pem certificate from app assets
+ * @param inputStream the original file stream
+ * @param signatureBytes the bytes of the encrypted signature
+ * @return whether the file signature check passed or not
+ * @throws IOException if there was an error while reading the file
+ */
+ public boolean verify(InputStream inputStream, byte[] signatureBytes) throws IOException {
+ byte[] ingestionBuffer = new byte[65535];
+ try {
+ Signature signature = Signature.getInstance("SHA256withRSA");
+ signature.initVerify(mPublicKey);
+ for (int i = 0; i != -1; i = inputStream.read(ingestionBuffer)) {
+ signature.update(ingestionBuffer, 0, i);
+ }
+ return signature.verify(signatureBytes);
+ }catch (InvalidKeyException | NoSuchAlgorithmException | SignatureException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ /**
+ * Reads in the cert.pem certificate from application assets and creates a SignatureCheckUtil
+ * to verify data against this certificate
+ * @param assetManager the AssetManager used to read the cert.pem file
+ * @return the SignatureCheckUtil instance
+ * @throws IOException if reading fails
+ */
+ public static SignatureCheckUtil create(AssetManager assetManager) throws IOException {
+ try (InputStream certificateStream = assetManager.open("cert.pem")) {
+ CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
+ Certificate certificate = certificateFactory.generateCertificate(certificateStream);
+ return new SignatureCheckUtil(certificate.getPublicKey());
+ }catch (CertificateException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java
new file mode 100644
index 0000000000..e5bbb7bd6b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/TouchControllerUtils.java
@@ -0,0 +1,114 @@
+package net.kdt.pojavlaunch.utils;
+
+import android.content.Context;
+import android.os.Vibrator;
+
+import top.fifthlight.touchcontroller.proxy.client.LauncherProxyClient;
+import top.fifthlight.touchcontroller.proxy.client.MessageTransport;
+import top.fifthlight.touchcontroller.proxy.client.android.transport.UnixSocketTransportKt;
+import top.fifthlight.touchcontroller.proxy.message.VibrateMessage;
+
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+import android.util.SparseIntArray;
+import android.view.MotionEvent;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.core.content.ContextCompat;
+
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+
+public class TouchControllerUtils {
+ private TouchControllerUtils() {
+ }
+
+ public static LauncherProxyClient proxyClient;
+ private static final String socketName = "Copper";
+
+ private static class VibrationHandler implements LauncherProxyClient.VibrationHandler {
+ private final Vibrator vibrator;
+
+ public VibrationHandler(Vibrator vibrator) {
+ this.vibrator = vibrator;
+ }
+
+ @Override
+ @SuppressWarnings("DEPRECATION")
+ public void vibrate(@NonNull VibrateMessage.Kind kind) {
+ vibrator.vibrate(LauncherPreferences.PREF_TOUCHCONTROLLER_VIBRATE_LENGTH);
+ }
+ }
+
+ private static final SparseIntArray pointerIdMap = new SparseIntArray();
+ private static int nextPointerId = 1;
+
+ public static void processTouchEvent(MotionEvent motionEvent, View view) {
+ if (proxyClient == null) {
+ return;
+ }
+ int pointerId;
+ switch (motionEvent.getActionMasked()) {
+ case MotionEvent.ACTION_DOWN:
+ pointerId = nextPointerId++;
+ pointerIdMap.put(motionEvent.getPointerId(0), pointerId);
+ proxyClient.addPointer(pointerId, motionEvent.getX(0) / view.getWidth(), motionEvent.getY(0) / view.getHeight());
+ break;
+ case MotionEvent.ACTION_POINTER_DOWN:
+ pointerId = nextPointerId++;
+ int actionIndex = motionEvent.getActionIndex();
+ pointerIdMap.put(motionEvent.getPointerId(actionIndex), pointerId);
+ proxyClient.addPointer(pointerId, motionEvent.getX(actionIndex) / view.getWidth(), motionEvent.getY(actionIndex) / view.getHeight());
+ break;
+ case MotionEvent.ACTION_MOVE:
+ for (int i = 0; i < motionEvent.getPointerCount(); i++) {
+ pointerId = pointerIdMap.get(motionEvent.getPointerId(i));
+ if (pointerId == 0) {
+ Log.d("TouchController", "Move pointerId is 0");
+ continue;
+ }
+ proxyClient.addPointer(pointerId, motionEvent.getX(i) / view.getWidth(), motionEvent.getY(i) / view.getHeight());
+ }
+ break;
+ case MotionEvent.ACTION_UP:
+ case MotionEvent.ACTION_CANCEL:
+ if (proxyClient != null) {
+ proxyClient.clearPointer();
+ pointerIdMap.clear();
+ }
+ break;
+ case MotionEvent.ACTION_POINTER_UP:
+ if (proxyClient != null) {
+ int i = motionEvent.getActionIndex();
+ pointerId = pointerIdMap.get(motionEvent.getPointerId(i));
+ if (pointerId == 0) {
+ Log.d("TouchController", "Pointer up pointerId is 0");
+ break;
+ }
+ pointerIdMap.delete(pointerId);
+ proxyClient.removePointer(pointerId);
+ }
+ break;
+ }
+ }
+
+ public static void initialize(Context context) {
+ if (proxyClient != null) {
+ return;
+ }
+ try {
+ Os.setenv("TOUCH_CONTROLLER_PROXY_SOCKET", socketName, true);
+ } catch (ErrnoException e) {
+ Log.w("TouchController", "Failed to set TouchController environment variable", e);
+ }
+ MessageTransport transport = UnixSocketTransportKt.UnixSocketTransport(socketName);
+ proxyClient = new LauncherProxyClient(transport);
+ proxyClient.run();
+ Vibrator vibrator = ContextCompat.getSystemService(context, Vibrator.class);
+ if (vibrator != null) {
+ LauncherProxyClient.VibrationHandler vibrationHandler = new VibrationHandler(vibrator);
+ proxyClient.setVibrationHandler(vibrationHandler);
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java
new file mode 100644
index 0000000000..86d6c8a056
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/GameRunner.java
@@ -0,0 +1,419 @@
+package net.kdt.pojavlaunch.utils.jre;
+
+import android.util.ArrayMap;
+import android.util.Log;
+import android.widget.Toast;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+
+import net.kdt.pojavlaunch.Architecture;
+import net.kdt.pojavlaunch.JMinecraftVersionList;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.authenticator.accounts.MinecraftAccount;
+import net.kdt.pojavlaunch.instances.Instance;
+import net.kdt.pojavlaunch.lifecycle.LifecycleAwareAlertDialog;
+import net.kdt.pojavlaunch.multirt.MultiRTUtils;
+import net.kdt.pojavlaunch.multirt.Runtime;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.DateUtils;
+import net.kdt.pojavlaunch.utils.FileUtils;
+import net.kdt.pojavlaunch.utils.GLInfoUtils;
+import net.kdt.pojavlaunch.utils.GameOptionsUtils;
+import net.kdt.pojavlaunch.utils.JREUtils;
+import net.kdt.pojavlaunch.utils.JSONUtils;
+import net.kdt.pojavlaunch.utils.MCOptionUtils;
+import net.kdt.pojavlaunch.utils.OldVersionsUtils;
+import net.kdt.pojavlaunch.utils.RendererCompatUtil;
+
+import java.io.File;
+import java.io.IOException;
+import java.text.ParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+import git.artdeell.mojo.R;
+
+public class GameRunner {
+ /**
+ * Optimization mods based on Sodium can mitigate the render distance issue. Check if Sodium
+ * or its derivative is currently installed to skip the render distance check.
+ * @param gameDir current game directory
+ * @return whether sodium or a sodium-based mod is installed
+ */
+ private static boolean hasSodium(File gameDir) {
+ File modsDir = new File(gameDir, "mods");
+ File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
+ if(mods == null) return false;
+ for(File file : mods) {
+ String name = file.getName();
+ if(name.contains("sodium") ||
+ name.contains("embeddium") ||
+ name.contains("rubidium")) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Check if Angelica is currently installed to allow usage of LTW
+ * @param gameDir current game directory
+ * @return whether Angelica is installed
+ */
+ private static boolean hasAngelica(File gameDir) {
+ File modsDir = new File(gameDir, "mods");
+ File[] mods = modsDir.listFiles(file -> file.isFile() && file.getName().endsWith(".jar"));
+ if(mods == null) return false;
+ for(File file : mods) {
+ String name = file.getName();
+ if(name.contains("angelica")) return true;
+ }
+ return false;
+ }
+
+ /**
+ * Initialize OpenGL and do checks to see if the GPU of the device is affected by the render
+ * distance issue.
+
+ * Currently only checks whether the user has an Adreno GPU capable of OpenGL ES 3.
+
+ * This issue is caused by a very severe limit on the amount of GL buffer names that could be allocated
+ * by the Adreno properietary GLES driver.
+
+ * @return whether the GPU is affected by the Large Thin Wrapper render distance issue on vanilla
+ */
+
+ private static boolean affectedByRenderDistanceIssue(JMinecraftVersionList.Version version) throws ParseException {
+ if(LauncherPreferences.PREF_USE_ANGLE) return false;
+ GLInfoUtils.GLInfo info = GLInfoUtils.getGlInfo();
+ return info.isAdreno() &&
+ info.glesMajorVersion >= 3 &&
+ // 1.21.5 fixes the RD issue, released on march 25 2025
+ DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2025, 2, 25);
+ }
+
+ private static boolean checkRenderDistance(JMinecraftVersionList.Version version, File gamedir) throws ParseException {
+ if(!affectedByRenderDistanceIssue(version)) return false;
+ if(hasSodium(gamedir)) return false;
+ try {
+ MCOptionUtils.load();
+ }catch (Exception e) {
+ Log.e("Tools", "Failed to load config", e);
+ }
+ int renderDistance = GameOptionsUtils.parseIntDefault(MCOptionUtils.get("renderDistance"),12);
+ // 7 is the render distance "magic number" above which MC creates too many buffers
+ // for Adreno's OpenGL ES implementation
+ return renderDistance > 7;
+ }
+
+ private static boolean isGl4esCompatible(JMinecraftVersionList.Version version) throws Exception{
+ return DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2025, 1, 7);
+ }
+
+ private static boolean isCompatContext(JMinecraftVersionList.Version version) throws Exception{
+ // Day before the release date of 21w10a, the first OpenGL 3 Core Minecraft version
+ return DateUtils.dateBefore(DateUtils.getOriginalReleaseDate(version), 2021, 3, 9);
+ }
+
+ private static boolean showDialog(AppCompatActivity activity, int message) throws InterruptedException {
+ LifecycleAwareAlertDialog.DialogCreator dialogCreator = ((alertDialog, dialogBuilder) ->
+ dialogBuilder.setMessage(activity.getString(message))
+ .setCancelable(false)
+ .setPositiveButton(android.R.string.ok, (d, w)->{}));
+ return LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator);
+ }
+
+ // Autoswitch to LTW if supported, otherwise - crash with resId dialog message. Returns LTW renderer strings if succeeded
+ private static String switchLtw(boolean hasLtw, Instance instance, AppCompatActivity activity, int resId) throws InterruptedException, IOException {
+ if(hasLtw) {
+ String ltwRenderer = "opengles3_ltw";
+ instance.renderer = ltwRenderer;
+ instance.write();
+ return ltwRenderer;
+ }else {
+ showDialog(activity, resId);
+ System.exit(0);
+ return null;
+ }
+ }
+
+ public static void launchMinecraft(final AppCompatActivity activity, MinecraftAccount minecraftAccount,
+ Instance instance, String versionId, File[] classpath, String rendererName) throws Throwable {
+ int freeDeviceMemory = Tools.getFreeDeviceMemory(activity);
+ int localeString;
+ int freeAddressSpace = Architecture.is32BitsDevice() ? Tools.getMaxContinuousAddressSpaceSize() : -1;
+ Log.i("MemStat", "Free RAM: " + freeDeviceMemory + " Addressable: " + freeAddressSpace);
+ if(freeDeviceMemory > freeAddressSpace && freeAddressSpace != -1) {
+ freeDeviceMemory = freeAddressSpace;
+ localeString = R.string.address_memory_warning_msg;
+ } else {
+ localeString = R.string.memory_warning_msg;
+ }
+
+ if(LauncherPreferences.PREF_RAM_ALLOCATION > freeDeviceMemory) {
+ int finalDeviceMemory = freeDeviceMemory;
+ LifecycleAwareAlertDialog.DialogCreator dialogCreator = (dialog, builder) ->
+ builder.setMessage(activity.getString(localeString, finalDeviceMemory, LauncherPreferences.PREF_RAM_ALLOCATION))
+ .setPositiveButton(android.R.string.ok, (d, w)->{});
+
+ if(LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator)) {
+ return; // If the dialog's lifecycle has ended, return without
+ // actually launching the game, thus giving us the opportunity
+ // to start after the activity is shown again
+ }
+ }
+ File gamedir = instance.getGameDirectory();
+ JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionId);
+
+ // Switch renderer to GL4ES when running a compat context version on LTW
+ if(isCompatContext(versionInfo) && !hasAngelica(gamedir) && rendererName.equals("opengles3_ltw")) {
+ instance.renderer = rendererName = "opengles2";
+ instance.write();
+ }
+
+ boolean isGl4es = rendererName.equals("opengles2");
+ boolean ltwSupported = RendererCompatUtil.getCompatibleRenderers(activity).rendererIds.contains("opengles3_ltw");
+ // Block Sodium from running with GL4ES on 1.17+
+ if(!isCompatContext(versionInfo) && isGl4es && hasSodium(gamedir)) {
+ rendererName = switchLtw(ltwSupported, instance, activity, R.string.compat_sodium_not_supported);
+ }
+
+ // Switch renderer to LTW when running 1.21.5
+ if(!isGl4esCompatible(versionInfo) && isGl4es) {
+ rendererName = switchLtw(ltwSupported, instance, activity, R.string.compat_version_not_supported);
+ }
+ RendererCompatUtil.releaseRenderersCache();
+
+ boolean isLtw = rendererName.equals("opengles3_ltw");
+
+ if(isLtw && checkRenderDistance(versionInfo, gamedir)) {
+ if(showDialog(activity, R.string.ltw_render_distance_warning_msg)) return;
+ // If the code goes here, it means that the user clicked "OK". Fix the render distance.
+ try {
+ MCOptionUtils.set("renderDistance", "7");
+ MCOptionUtils.save();
+ }catch (Exception e) {
+ Log.e("Tools", "Failed to fix render distance setting", e);
+ }
+ }
+
+ GameOptionsUtils.fixOptions(isLtw);
+
+ if(isLtw && GLInfoUtils.getGlInfo().forcedMsaa) {
+ if(showDialog(activity, R.string.ltw_4x_msaa_warning_msg)) return;
+ }
+
+ int requiredJavaVersion = 8;
+ if(versionInfo.javaVersion != null) requiredJavaVersion = versionInfo.javaVersion.majorVersion;
+
+ Runtime runtime = MultiRTUtils.forceReread(pickRuntime(instance, requiredJavaVersion));
+
+ // Pre-process specific files
+ disableSplash(gamedir);
+ List launchArgs = getMinecraftClientArgs(minecraftAccount, versionInfo, gamedir);
+
+ // Select the appropriate openGL version
+ OldVersionsUtils.selectOpenGlVersion(versionInfo);
+
+ ArrayList launchClassPath = new ArrayList<>(classpath.length);
+ for(File classpathEntry : classpath) {
+ String entryPath = classpathEntry.getAbsolutePath();
+ if(!classpathEntry.exists()) {
+ Log.w("GameRunner", "Skipped classpath entry " + entryPath + " because it is missing");
+ }
+ launchClassPath.add(entryPath);
+ }
+ launchClassPath.trimToSize();
+
+ List javaArgList = new ArrayList<>();
+
+ if (versionInfo.logging != null && versionInfo.logging.client != null && versionInfo.logging.client.file != null) {
+ String configFile = Tools.DIR_DATA + "/security/" + versionInfo.logging.client.file.id.replace("client", "log4j-rce-patch");
+ if (!new File(configFile).exists()) {
+ configFile = Tools.DIR_GAME_NEW + "/" + versionInfo.logging.client.file.id;
+ }
+ javaArgList.add("-Dlog4j.configurationFile=" + configFile);
+ }
+
+ File versionSpecificNativesDir = new File(Tools.DIR_CACHE, "natives/"+versionId);
+ if(versionSpecificNativesDir.exists()) {
+ String dirPath = versionSpecificNativesDir.getAbsolutePath();
+ javaArgList.add("-Djava.library.path="+dirPath+":"+Tools.NATIVE_LIB_DIR);
+ javaArgList.add("-Djna.boot.library.path="+dirPath);
+ }
+
+ File lwjglExtractDir = new File(Tools.DIR_CACHE, "lwjgl_native/"+versionId);
+ FileUtils.ensureDirectory(lwjglExtractDir);
+ javaArgList.add("-Dorg.lwjgl.system.SharedLibraryExtractPath="+lwjglExtractDir.getAbsolutePath());
+
+ addAuthlibInjectorArgs(javaArgList, minecraftAccount);
+
+ javaArgList.addAll(getMinecraftJVMArgs(versionId));
+
+ javaArgList.addAll(JREUtils.parseJavaArguments(instance.getLaunchArgs()));
+
+ JREUtils.setEnviroimentForGame(activity, rendererName);
+ JREUtils.chdir(instance.getGameDirectory().getAbsolutePath());
+
+ String rendererLibrary = JREUtils.loadGraphicsLibrary(rendererName);
+ if(rendererLibrary == null) {
+ Log.i("GameRunner", "Falling back to GL4ES 1.1.4");
+ rendererName = "opengles2";
+ rendererLibrary = JREUtils.loadGraphicsLibrary(rendererName);
+ }
+ if(rendererLibrary == null) {
+ if(showDialog(activity, R.string.gr_err_renderer_load_Failed)) return;
+ System.exit(0);
+ }
+ javaArgList.add("-Dorg.lwjgl.opengl.libname=libGLMojo.so");
+ javaArgList.add("-Dorg.lwjgl.freetype.libname="+ Tools.NATIVE_LIB_DIR+"/libfreetype.so");
+
+ activity.runOnUiThread(() -> Toast.makeText(activity, activity.getString(R.string.autoram_info_msg,LauncherPreferences.PREF_RAM_ALLOCATION), Toast.LENGTH_SHORT).show());
+
+ Log.i("GameRunner", "Running with "+ launchArgs.toString());
+
+ try {
+ JavaRunner.nativeSetupExit(activity);
+ JavaRunner.startJvm(runtime, javaArgList, launchClassPath, versionInfo.mainClass, launchArgs);
+ }catch (VMLoadException e) {
+ LifecycleAwareAlertDialog.DialogCreator dialogCreator = (dialog, builder) ->
+ builder.setMessage(e.toString(activity)).setPositiveButton(android.R.string.ok, (d, w)->{});
+
+ if(LifecycleAwareAlertDialog.haltOnDialog(activity.getLifecycle(), activity, dialogCreator)) {
+ return;
+ }
+ }
+
+ Tools.fullyExit();
+ }
+
+ private static void disableSplash(File dir) {
+ File configDir = new File(dir, "config");
+ if(FileUtils.ensureDirectorySilently(configDir)) {
+ File forgeSplashFile = new File(dir, "config/splash.properties");
+ String forgeSplashContent = "enabled=true";
+ try {
+ if (forgeSplashFile.exists()) {
+ forgeSplashContent = Tools.read(forgeSplashFile.getAbsolutePath());
+ }
+ if (forgeSplashContent.contains("enabled=true")) {
+ Tools.write(forgeSplashFile,
+ forgeSplashContent.replace("enabled=true", "enabled=false"));
+ }
+ } catch (IOException e) {
+ Log.w(Tools.APP_NAME, "Could not disable Forge 1.12.2 and below splash screen!", e);
+ }
+ } else {
+ Log.w(Tools.APP_NAME, "Failed to create the configuration directory");
+ }
+ }
+
+ private static void addAuthlibInjectorArgs(List javaArgList, MinecraftAccount minecraftAccount) {
+ String injectorUrl = minecraftAccount.authType.injectorUrl;
+ if(injectorUrl == null) return;
+ javaArgList.add("-javaagent:"+Tools.DIR_DATA+"/authlib-injector/authlib-injector.jar="+injectorUrl);
+ }
+
+ private static List getMinecraftJVMArgs(String versionName) {
+ JMinecraftVersionList.Version versionInfo = Tools.getVersionInfo(versionName, true);
+ // Parse Forge 1.17+ additional JVM Arguments
+ if (versionInfo.inheritsFrom == null || versionInfo.arguments == null || versionInfo.arguments.jvm == null) {
+ return Collections.emptyList();
+ }
+
+ Map varArgMap = new ArrayMap<>();
+ varArgMap.put("classpath_separator", ":");
+ varArgMap.put("library_directory", Tools.DIR_HOME_LIBRARY);
+ varArgMap.put("version_name", versionInfo.id);
+ varArgMap.put("natives_directory", Tools.NATIVE_LIB_DIR);
+
+ List minecraftArgs = new ArrayList<>();
+ if (versionInfo.arguments != null) {
+ for (Object arg : versionInfo.arguments.jvm) {
+ if (arg instanceof String) {
+ minecraftArgs.add((String) arg);
+ } //TODO: implement (?maybe?)
+ }
+ }
+ return JSONUtils.insertJSONValueList(minecraftArgs, varArgMap);
+ }
+
+ private static List getMinecraftClientArgs(MinecraftAccount profile, JMinecraftVersionList.Version versionInfo, File gameDir) {
+ String username = profile.username;
+ String versionName = versionInfo.id;
+ if (versionInfo.inheritsFrom != null) {
+ versionName = versionInfo.inheritsFrom;
+ }
+
+ String userType = "mojang";
+ try {
+ Date creationDate = DateUtils.getOriginalReleaseDate(versionInfo);
+ // Minecraft 22w43a which adds chat reporting (and signing) was released on
+ // 26th October 2022. So, if the date is not before that (meaning it is equal or higher)
+ // change the userType to MSA to fix the missing signature
+ if(creationDate != null && !DateUtils.dateBefore(creationDate, 2022, 9, 26)) {
+ userType = "msa";
+ }
+ }catch (ParseException e) {
+ Log.e("CheckForProfileKey", "Failed to determine profile creation date, using \"mojang\"", e);
+ }
+
+
+ Map varArgMap = new ArrayMap<>();
+ varArgMap.put("auth_session", profile.accessToken); // For legacy versions of MC
+ varArgMap.put("auth_access_token", profile.accessToken);
+ varArgMap.put("auth_player_name", username);
+ varArgMap.put("auth_uuid", profile.profileId.replace("-", ""));
+ varArgMap.put("auth_xuid", profile.xuid);
+ varArgMap.put("assets_root", Tools.ASSETS_PATH);
+ varArgMap.put("assets_index_name", versionInfo.assets);
+ varArgMap.put("game_assets", Tools.ASSETS_PATH);
+ varArgMap.put("game_directory", gameDir.getAbsolutePath());
+ varArgMap.put("user_properties", "{}");
+ varArgMap.put("user_type", userType);
+ varArgMap.put("version_name", versionName);
+ varArgMap.put("version_type", versionInfo.type);
+
+ List minecraftArgs = new ArrayList<>();
+ if (versionInfo.arguments != null && versionInfo.arguments.game != null) {
+ // Support Minecraft 1.13+
+ for (Object arg : versionInfo.arguments.game) {
+ if (arg instanceof String) {
+ minecraftArgs.add((String) arg);
+ } //TODO: implement else clause
+ }
+ }
+ if(versionInfo.minecraftArguments != null){
+ minecraftArgs.addAll(splitAndFilterEmpty(versionInfo.minecraftArguments));
+ }
+ return JSONUtils.insertJSONValueList(minecraftArgs, varArgMap);
+ }
+
+ private static List splitAndFilterEmpty(String argStr) {
+ List strList = new ArrayList<>();
+ for (String arg : argStr.split(" ")) {
+ if (!arg.isEmpty()) {
+ strList.add(arg);
+ }
+ }
+ return strList;
+ }
+
+ public static @NonNull String pickRuntime(Instance instance, int targetJavaVersion) {
+ String runtime = Tools.getSelectedRuntime(instance);
+ String profileRuntime = instance.selectedRuntime;
+ Runtime pickedRuntime = MultiRTUtils.read(runtime);
+ if(runtime == null || pickedRuntime.javaVersion == 0 || pickedRuntime.javaVersion < targetJavaVersion) {
+ String preferredRuntime = MultiRTUtils.getNearestJreName(targetJavaVersion);
+ if(preferredRuntime == null) throw new RuntimeException("Failed to autopick runtime!");
+ if(profileRuntime != null) {
+ instance.selectedRuntime = preferredRuntime;
+ instance.maybeWrite();
+ }
+ runtime = preferredRuntime;
+ }
+ return runtime;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java
new file mode 100644
index 0000000000..4ff6c0c39b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/JavaRunner.java
@@ -0,0 +1,292 @@
+package net.kdt.pojavlaunch.utils.jre;
+
+import static net.kdt.pojavlaunch.Tools.NATIVE_LIB_DIR;
+
+import android.content.Context;
+import android.os.Build;
+import android.system.ErrnoException;
+import android.system.Os;
+import android.util.Log;
+
+import androidx.annotation.NonNull;
+
+import net.kdt.pojavlaunch.AWTCanvasView;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.multirt.MultiRTUtils;
+import net.kdt.pojavlaunch.multirt.Runtime;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+import net.kdt.pojavlaunch.utils.JREUtils;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.ListIterator;
+import java.util.Objects;
+import java.util.TimeZone;
+
+public class JavaRunner {
+
+ private static boolean getCacioJavaArgs(List javaArgList, boolean isJava8) {
+ // Caciocavallo config AWT-enabled version
+ javaArgList.add("-Djava.awt.headless=false");
+ javaArgList.add("-Dcacio.managed.screensize=" + AWTCanvasView.AWT_CANVAS_WIDTH + "x" + AWTCanvasView.AWT_CANVAS_HEIGHT);
+ javaArgList.add("-Dcacio.font.fontmanager=sun.awt.X11FontManager");
+ javaArgList.add("-Dcacio.font.fontscaler=sun.font.FreetypeFontScaler");
+ javaArgList.add("-Dswing.defaultlaf=javax.swing.plaf.metal.MetalLookAndFeel");
+ if (isJava8) {
+ javaArgList.add("-Dawt.toolkit=net.java.openjdk.cacio.ctc.CTCToolkit");
+ javaArgList.add("-Djava.awt.graphicsenv=net.java.openjdk.cacio.ctc.CTCGraphicsEnvironment");
+ StringBuilder cacioClasspath = createCacioClasspath();
+ javaArgList.add(cacioClasspath.toString());
+ return false;
+ } else {
+ File caciocavallo17AgentDir = new File(Tools.DIR_GAME_HOME, "caciocavallo17");
+ File[] cacioJars = caciocavallo17AgentDir.listFiles((file, s) ->s.endsWith(".jar"));
+ if(cacioJars == null || cacioJars.length < 1) {
+ return false;
+ }
+ javaArgList.add("-javaagent:"+cacioJars[0].getAbsolutePath());
+ javaArgList.add("-Dawt.toolkit=com.github.caciocavallosilano.cacio.ctc.CTCToolkit");
+ javaArgList.add("-Djava.awt.graphicsenv=com.github.caciocavallosilano.cacio.ctc.CTCGraphicsEnvironment");
+
+ javaArgList.add("--add-exports=java.desktop/java.awt=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/java.awt.peer=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.awt.image=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.java2d=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/java.awt.dnd.peer=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.awt=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.awt.event=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.awt.datatransfer=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.desktop/sun.font=ALL-UNNAMED");
+ javaArgList.add("--add-exports=java.base/sun.security.action=ALL-UNNAMED");
+ javaArgList.add("--add-opens=java.base/java.util=ALL-UNNAMED");
+ javaArgList.add("--add-opens=java.desktop/java.awt=ALL-UNNAMED");
+ javaArgList.add("--add-opens=java.desktop/sun.font=ALL-UNNAMED");
+ javaArgList.add("--add-opens=java.desktop/sun.java2d=ALL-UNNAMED");
+ javaArgList.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED");
+ return true;
+ }
+ }
+
+ @NonNull
+ private static StringBuilder createCacioClasspath() {
+ StringBuilder cacioClasspath = new StringBuilder();
+ cacioClasspath.append("-Xbootclasspath/p");
+ File cacioDir = new File(Tools.DIR_GAME_HOME, "caciocavallo");
+ File[] cacioFiles = cacioDir.listFiles();
+ if (cacioFiles != null) {
+ for (File file : cacioFiles) {
+ if (file.getName().endsWith(".jar")) {
+ cacioClasspath.append(":").append(file.getAbsolutePath());
+ }
+ }
+ }
+ return cacioClasspath;
+ }
+
+ /**
+ * Gives an argument list filled with both the user args
+ * and the auto-generated ones (eg. the window resolution).
+ * @return A list filled with args.
+ */
+ private static List getJavaArgs(String runtimeHome, List userArguments) {
+ String resolvFile;
+ resolvFile = new File(Tools.DIR_DATA,"resolv.conf").getAbsolutePath();
+
+ userArguments.add(0, "-Xms"+LauncherPreferences.PREF_RAM_ALLOCATION+"M");
+ userArguments.add(0, "-Xmx"+LauncherPreferences.PREF_RAM_ALLOCATION+"M");
+
+ ArrayList overridableArguments = new ArrayList<>(Arrays.asList(
+ "-Djava.home=" + runtimeHome,
+ "-Djava.io.tmpdir=" + Tools.DIR_CACHE.getAbsolutePath(),
+ "-Djna.boot.library.path=" + NATIVE_LIB_DIR,
+ "-Duser.home=" + Tools.DIR_GAME_HOME,
+ "-Duser.language=" + System.getProperty("user.language"),
+ "-Dos.name=Linux",
+ "-Dos.version=Android-" + Build.VERSION.RELEASE,
+ "-Dpojav.path.minecraft=" + Tools.DIR_GAME_NEW,
+ "-Dpojav.path.private.account=" + Tools.DIR_ACCOUNT_NEW,
+ "-Duser.timezone=" + TimeZone.getDefault().getID(),
+
+ "-Dorg.lwjgl.vulkan.libname=libvulkan.so",
+ "-Dorg.lwjgl.spvc.libname=spirv-cross-c-shared",
+ "-Dorg.lwjgl.system.allocator=system",
+ //LWJGL 3 DEBUG FLAGS
+ //"-Dorg.lwjgl.util.Debug=true",
+ //"-Dorg.lwjgl.util.DebugFunctions=true",
+ //"-Dorg.lwjgl.util.DebugLoader=true",
+ // GLFW Stub width height
+ "-Dglfwstub.initEgl=false",
+ "-Dext.net.resolvPath=" +resolvFile,
+ "-Dlog4j2.formatMsgNoLookups=true", //Log4j RCE mitigation
+ "-Dfml.earlyprogresswindow=false", //Forge 1.14+ workaround
+ "-Dloader.disable_forked_guis=true",
+ "-Dsodium.checks.issue2561=false",
+ "-Djdk.lang.Process.launchMechanism=FORK" // Default is POSIX_SPAWN which requires starting jspawnhelper, which doesn't work on Android
+ ));
+ List additionalArguments = new ArrayList<>();
+ for(String arg : overridableArguments) {
+ String strippedArg = arg.substring(0,arg.indexOf('='));
+ boolean add = true;
+ for(String uarg : userArguments) {
+ if(uarg.startsWith(strippedArg)) {
+ add = false;
+ break;
+ }
+ }
+ if(add)
+ additionalArguments.add(arg);
+ else
+ Log.i("ArgProcessor","Arg skipped: "+arg);
+ }
+
+ //Add all the arguments
+ userArguments.addAll(additionalArguments);
+ return userArguments;
+ }
+
+ private static File getVmPath(File runtimeHomeDir, String arch, String flavor) {
+ if(arch != null) return new File(runtimeHomeDir, "lib/"+arch+"/"+flavor+"/libjvm.so");
+ else return new File(runtimeHomeDir, "lib/"+flavor+"/libjvm.so");
+ }
+
+ private static File findVmForArch(File runtimeHomeDir, String arch) {
+ File finalPath;
+ if((finalPath = getVmPath(runtimeHomeDir, arch, "server")).exists()) return finalPath;
+ if((finalPath = getVmPath(runtimeHomeDir, arch, "client")).exists()) return finalPath;
+ return null;
+ }
+
+ private static File findVmPath(File runtimeHomeDir, String runtimeArch) {
+ File finalPath;
+ if((finalPath = findVmForArch(runtimeHomeDir, null)) != null) return finalPath;
+ switch (runtimeArch) {
+ case "i386": case "i486": case "i586":
+ if((finalPath = findVmForArch(runtimeHomeDir, "i386")) != null) return finalPath;
+ if((finalPath = findVmForArch(runtimeHomeDir, "i486")) != null) return finalPath;
+ if((finalPath = findVmForArch(runtimeHomeDir, "i586")) != null) return finalPath;
+ break;
+ default:
+ if((finalPath = findVmForArch(runtimeHomeDir, runtimeArch)) != null) return finalPath;
+ }
+ return null;
+ }
+
+ private static void relocateLdLibPath(File vmPath, List extraDirs) {
+ // Java directory layout:
+ // .../server/libjvm.so
+ // .../libjava.so
+ // and so on. Hotspot itself relies on this we also rely on this.
+ File vmDir = Objects.requireNonNull(vmPath.getParentFile());
+ File libsDir = Objects.requireNonNull(vmDir.getParentFile());
+ StringBuilder libPathBuilder = new StringBuilder()
+ .append(libsDir.getAbsolutePath()).append(":")
+ .append(NATIVE_LIB_DIR).append(':')
+ .append(vmDir.getAbsolutePath()).append(':')
+ .append(new File(libsDir, "jli").getAbsolutePath());
+
+ if(extraDirs != null) for(String path : extraDirs) {
+ libPathBuilder.append(':').append(path);
+ }
+
+ String ldLibPath = libPathBuilder.toString();
+ try {
+ Os.setenv("LD_LIBRARY_PATH", ldLibPath, true);
+ }catch (ErrnoException e) {
+ throw new RuntimeException(e);
+ }
+ JREUtils.setLdLibraryPath(ldLibPath);
+ }
+
+ private static void setImmutableEnvVars(File jreHome) {
+ try {
+ Os.setenv("POJAV_NATIVEDIR", NATIVE_LIB_DIR, true);
+ Os.setenv("JAVA_HOME", jreHome.getAbsolutePath(), true);
+ Os.setenv("HOME", Tools.DIR_GAME_HOME, true);
+ Os.setenv("TMPDIR", Tools.DIR_CACHE.getAbsolutePath(), true);
+ }catch (ErrnoException e) {
+ throw new RuntimeException(e);
+ }
+ }
+
+ private static boolean preprocessUserArgs(List args) {
+ ListIterator iterator = args.listIterator();
+ boolean hasJavaAgent = false;
+ while(iterator.hasNext()) {
+ String arg = iterator.next();
+ switch (arg) {
+ case "-p":
+ arg = "--module-path";
+ case "--add-reads":
+ case "--add-exports":
+ case "--add-opens":
+ case "--add-modules":
+ case "--limit-modules":
+ case "--module-path":
+ case "--patch-module":
+ case "--upgrade-module-path":
+ iterator.remove();
+ String argValue = iterator.next();
+ iterator.remove();
+ iterator.add(arg+"="+argValue);
+ break;
+ case "-d32":
+ case "-d64":
+ case "-Xint":
+ case "-XX:+UseTransparentHugePages":
+ case "-XX:+UseLargePagesInMetaspace":
+ case "-XX:+UseLargePages":
+ iterator.remove();
+ break;
+ default:
+ if(arg.startsWith("-Xms") || arg.startsWith("-Xmx") || arg.startsWith("-XX:ActiveProcessorCount")) iterator.remove();
+ if(!hasJavaAgent && arg.startsWith("-javaagent:")) hasJavaAgent = true;
+ }
+ }
+ return hasJavaAgent;
+ }
+
+ /**
+ * Start the Java(tm) Virtual Machine.
+ * @param runtime the Runtime that we're starting.
+ * @param vmArgs the command line parameters for the virtual machine
+ * @param classpathEntries the absolute path for each classpath entry
+ * @param mainClass the application main class
+ * @param applicationArgs the application arguments
+ * @throws VMLoadException if an error occurred during VM loading
+ */
+ public static void startJvm(Runtime runtime, List vmArgs, List classpathEntries, String mainClass, List applicationArgs) throws VMLoadException{
+ File runtimeHomeDir = MultiRTUtils.getRuntimeHome(runtime.name);
+ File vmPath = findVmPath(runtimeHomeDir, runtime.arch);
+ if(vmPath == null) {
+ throw new VMLoadException("Unable to find the Java VM", 0, -1);
+ }
+
+ boolean hasJavaAgent = preprocessUserArgs(vmArgs);
+ List runtimeArgs = new ArrayList<>();
+ if(getCacioJavaArgs(runtimeArgs,runtime.javaVersion == 8)) hasJavaAgent = true;
+ runtimeArgs.addAll(getJavaArgs(runtimeHomeDir.getAbsolutePath(), vmArgs));
+
+
+ runtimeArgs.add("-XX:ActiveProcessorCount=" + java.lang.Runtime.getRuntime().availableProcessors());
+ StringBuilder classpathBuilder = new StringBuilder().append("-Djava.class.path=");
+ boolean first = true;
+ for(String entry : classpathEntries) {
+ if(first) first = false;
+ else classpathBuilder.append(':');
+ classpathBuilder.append(entry);
+ }
+ runtimeArgs.add(classpathBuilder.toString());
+
+ //JREUtils.initializeHooks();
+
+ setImmutableEnvVars(runtimeHomeDir);
+ relocateLdLibPath(vmPath, null);
+
+ nativeLoadJVM(vmPath.getAbsolutePath(), runtimeArgs.toArray(new String[0]), mainClass, applicationArgs.toArray(new String[0]), hasJavaAgent);
+ }
+
+ public static native boolean nativeLoadJVM(String vmPath, String[] javaArgs, String mainClass, String[] appArgs, boolean hasJavaAgents) throws VMLoadException;
+ public static native void nativeSetupExit(Context context);
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java
new file mode 100644
index 0000000000..857e954f58
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/RuntimeSelectionException.java
@@ -0,0 +1,51 @@
+package net.kdt.pojavlaunch.utils.jre;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+
+import net.kdt.pojavlaunch.ShowErrorActivity;
+import net.kdt.pojavlaunch.Tools;
+import net.kdt.pojavlaunch.lifecycle.ContextExecutorTask;
+
+import git.artdeell.mojo.R;
+
+public class RuntimeSelectionException extends Exception implements ContextExecutorTask {
+ // Do not change. Android really hates when this value changes for some reason.
+ private static final long serialVersionUID = -7482301619612640658L;
+ public static final int RUNTIME_STATE_INSTALLATION_FAILED = 0;
+ public static final int RUNTIME_STATE_SELECTION_FAILED = 1;
+ public static final int RUNTIME_STATE_INTERNAL_RUNTIME_MISSING = 2;
+ private final int mRuntimeState;
+ private final int mRuntimeVersion;
+
+ public RuntimeSelectionException(int mRuntimeState, int mRuntimeRequiredVersion) {
+ this.mRuntimeState = mRuntimeState;
+ this.mRuntimeVersion = mRuntimeRequiredVersion;
+ }
+
+ @Override
+ public void executeWithActivity(Activity activity) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(activity);
+ builder.setTitle(R.string.runtime_error_title);
+ int msgString;
+ switch (mRuntimeState) {
+ case RUNTIME_STATE_INSTALLATION_FAILED: msgString = R.string.runtime_error_install_failed; break;
+ case RUNTIME_STATE_INTERNAL_RUNTIME_MISSING: msgString = R.string.runtime_error_missing; break;
+ case RUNTIME_STATE_SELECTION_FAILED: msgString = R.string.multirt_nocompatiblert; break;
+ default: throw new RuntimeException("Unknown runtime state");
+ }
+ builder.setMessage(activity.getString(msgString, mRuntimeVersion));
+ builder.setPositiveButton(android.R.string.ok, (d,i)->{});
+ if(mRuntimeState == RUNTIME_STATE_INSTALLATION_FAILED || getCause() != null) {
+ builder.setNegativeButton(R.string.error_show_more, (d, i)->
+ Tools.showError(activity, R.string.runtime_error_title, getCause(), activity instanceof ShowErrorActivity)
+ );
+ }
+ ShowErrorActivity.installRemoteDialogHandling(activity, builder);
+ builder.show();
+ }
+
+ @Override
+ public void executeWithApplication(Context context) {}
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java
new file mode 100644
index 0000000000..86dff21f23
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/jre/VMLoadException.java
@@ -0,0 +1,58 @@
+package net.kdt.pojavlaunch.utils.jre;
+
+import android.content.Context;
+
+import androidx.annotation.NonNull;
+
+import git.artdeell.mojo.R;
+
+public class VMLoadException extends Exception {
+ private final int loadStep;
+ private final int errorCode;
+ public VMLoadException(String errorInfo, int loadStep, int errorCode) {
+ super(errorInfo);
+ this.loadStep = loadStep;
+ this.errorCode = errorCode;
+ }
+
+ private static int getLoadStepRes(int loadStep) {
+ switch (loadStep) {
+ case 0: return R.string.vml_fail_load_runtime;
+ case 1: return R.string.vml_fail_create_runtime;
+ case 2: return R.string.vml_fail_find_hooks_native;
+ case 3: return R.string.vml_fail_find_hooks;
+ case 4: return R.string.vml_fail_insert_hooks;
+ case 5: return R.string.vml_fail_load_classpath;
+ case 6: return R.string.vml_fail_run_main;
+ default: return R.string.vml_huh;
+ }
+ }
+
+ private static int getErrorCodeRes(int errorCode) {
+ switch (errorCode) {
+ case 0: return R.string.vml_err_ok;
+ case -2: return R.string.vml_err_detached;
+ case -3: return R.string.vml_err_version;
+ case -4: return R.string.vml_err_nomem;
+ case -5: return R.string.vml_err_exists;
+ case -6: return R.string.vml_err_inval;
+ case -1:
+ default: return R.string.vml_err_unknown;
+ }
+ }
+
+ @NonNull
+ public String toString(Context context) {
+ int loadStepRes = getLoadStepRes(loadStep);
+ switch (loadStep) {
+ case 0:
+ return context.getString(loadStepRes, getMessage());
+ case 1:
+ case 4:
+ return context.getString(loadStepRes, context.getString(getErrorCodeRes(errorCode)));
+ default:
+ return context.getString(loadStepRes);
+ }
+
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java
new file mode 100644
index 0000000000..9a0b177461
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/MemoryHoleFinder.java
@@ -0,0 +1,22 @@
+package net.kdt.pojavlaunch.utils.memory;
+
+import net.kdt.pojavlaunch.Architecture;
+
+public class MemoryHoleFinder implements SelfMapsParser.Callback {
+ private long mPreviousEnd = 0;
+ private long mLargestHole = -1;
+ private final long mAddressingLimit = Architecture.getAddressSpaceLimit();
+ @Override
+ public boolean process(long begin, long end, String wholeLine) {
+ if(begin >= mAddressingLimit) begin = mAddressingLimit;
+ long holeSize = begin - mPreviousEnd;
+ if(mLargestHole < holeSize) mLargestHole = holeSize;
+ if(begin == mAddressingLimit) return false;
+ mPreviousEnd = end;
+ return true;
+ }
+
+ public long getLargestHole() {
+ return mLargestHole;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java
new file mode 100644
index 0000000000..fa7d2252e9
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/memory/SelfMapsParser.java
@@ -0,0 +1,35 @@
+package net.kdt.pojavlaunch.utils.memory;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.Scanner;
+
+public class SelfMapsParser {
+ private final Callback mCallback;
+ public SelfMapsParser(Callback callback) {
+ mCallback = callback;
+ }
+
+ public void run() throws IOException, NumberFormatException {
+ try (FileInputStream fileInputStream = new FileInputStream("/proc/self/maps")) {
+ Scanner scanner = new Scanner(fileInputStream);
+ while(scanner.hasNextLine()) {
+ if(!forEachLine(scanner.nextLine())) break;
+ }
+ }
+ }
+
+ private boolean forEachLine(String line) throws NumberFormatException {
+ int firstSpaceIndex = line.indexOf(' ');
+ String addresses = line.substring(0, firstSpaceIndex);
+ String[] addressArray = addresses.split("-");
+ if(addressArray.length < 2) return true;
+ long begin = Long.parseLong(addressArray[0], 16);
+ long end = Long.parseLong(addressArray[1], 16);
+ return mCallback.process(begin, end, line);
+ }
+
+ public interface Callback {
+ boolean process(long startAddress, long endAddress, String wholeLine);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java
new file mode 100644
index 0000000000..c41e38866b
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/ExtractSettings.java
@@ -0,0 +1,7 @@
+package net.kdt.pojavlaunch.value;
+
+import java.util.List;
+
+public class ExtractSettings {
+ public List exclude;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java
new file mode 100644
index 0000000000..218e6b0a6f
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/LibrarySubstitution.java
@@ -0,0 +1,5 @@
+package net.kdt.pojavlaunch.value;
+
+public class LibrarySubstitution extends DependentLibrary {
+ public boolean skip;
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java
index 68c871c95b..9757202dc9 100644
--- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java
@@ -13,6 +13,7 @@
import android.util.Base64;
import androidx.annotation.Keep;
+import androidx.annotation.Nullable;
import org.apache.commons.io.IOUtils;
@@ -45,7 +46,11 @@ void updateSkinFace(String uuid) {
}
public boolean isLocal(){
- return accessToken.equals("0");
+ return false;
+ }
+
+ public boolean isDemo(){
+ return false;
}
public void updateSkinFace() {
@@ -64,7 +69,7 @@ public String save() throws IOException {
public static MinecraftAccount parse(String content) throws JsonSyntaxException {
return Tools.GLOBAL_GSON.fromJson(content, MinecraftAccount.class);
}
-
+ @Nullable
public static MinecraftAccount load(String name) {
if(!accountExists(name)) return null;
try {
@@ -88,7 +93,7 @@ public static MinecraftAccount load(String name) {
acc.msaRefreshToken = "0";
}
return acc;
- } catch(IOException | JsonSyntaxException e) {
+ } catch(NullPointerException | IOException | JsonSyntaxException e) {
Log.e(MinecraftAccount.class.getName(), "Caught an exception while loading the profile",e);
return null;
}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java
new file mode 100644
index 0000000000..e37356c449
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MoJsonRule.java
@@ -0,0 +1,58 @@
+package net.kdt.pojavlaunch.value;
+
+import net.kdt.pojavlaunch.Architecture;
+
+public class MoJsonRule {
+ public String action;
+ public OSDescriptor os;
+
+ public int getPrecedenceLevel() {
+ if(os == null) return 1;
+ return 1 + os.getPrecedenceLevel();
+ }
+
+ public boolean matches() {
+ if(os == null) return true;
+ else return os.matches();
+ }
+
+ public static String ruleSetCheck(MoJsonRule[] rules) {
+ int precedenceLevel = 0;
+ String action = "disallow";
+ for(MoJsonRule rule : rules) {
+ int ruleLevel = rule.getPrecedenceLevel();
+ if(ruleLevel <= precedenceLevel) {
+ continue;
+ }
+ if(rule.matches()) action = rule.action;
+ precedenceLevel = ruleLevel;
+ }
+ return action;
+ }
+
+ public static class OSDescriptor {
+ public String name;
+ public String version;
+ public String arch;
+
+ public int getPrecedenceLevel() {
+ int precedence = 0;
+ if(name != null) precedence += 1;
+ if(version != null) precedence += 2;
+ if(arch != null) precedence += 3;
+ return precedence;
+ }
+
+ private static boolean propertyMatches(String value, String expected) {
+ if(value == null) return true;
+ return value.equals(expected);
+ }
+
+ public boolean matches() {
+ // TODO: version matching
+ return propertyMatches(name, "linux") &&
+ propertyMatches(arch, Architecture.archAsString(Architecture.getDeviceArchitecture())) &&
+ version == null;
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java
new file mode 100644
index 0000000000..be0fe9abf8
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/NativeLibraryExtractable.java
@@ -0,0 +1,13 @@
+package net.kdt.pojavlaunch.value;
+
+import java.io.File;
+
+public class NativeLibraryExtractable {
+ public final File path;
+ public final ExtractSettings extractInfo;
+
+ public NativeLibraryExtractable(File path, ExtractSettings extractInfo) {
+ this.path = path;
+ this.extractInfo = extractInfo;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java
new file mode 100644
index 0000000000..06fe2caee1
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/SubstitutionMap.java
@@ -0,0 +1,21 @@
+package net.kdt.pojavlaunch.value;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class SubstitutionMap {
+ public LibraryMap libraries;
+ public Map artifactMapping;
+
+ public LibrarySubstitution findSubstitution(String name) {
+ if(!name.startsWith("org.lwjgl") && !name.startsWith("net.java.jinput")) return null;
+
+ LibrarySubstitution library = libraries.get(name);
+ if(library != null) return library;
+ String mapping = artifactMapping.get(name);
+ if(mapping == null) return null;
+ return libraries.get(mapping);
+ }
+
+ public static class LibraryMap extends HashMap {}
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java
new file mode 100644
index 0000000000..988f348dbb
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java
@@ -0,0 +1,26 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+ public int getId();
+ public int getVendorId();
+ public int getProductId();
+ public String getSerialNumber();
+ public int getVersion();
+ public String getManufacturerName();
+ public String getProductName();
+ public UsbDevice getDevice();
+ public boolean open();
+ public int writeReport(byte[] report, boolean feature);
+ public boolean readReport(byte[] report, boolean feature);
+ public void setFrozen(boolean frozen);
+ public void close();
+ public void shutdown();
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
new file mode 100644
index 0000000000..f61518ae38
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
@@ -0,0 +1,650 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.content.Context;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.UUID;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+ private HIDDeviceManager mManager;
+ private BluetoothDevice mDevice;
+ private int mDeviceId;
+ private BluetoothGatt mGatt;
+ private boolean mIsRegistered = false;
+ private boolean mIsConnected = false;
+ private boolean mIsChromebook = false;
+ private boolean mIsReconnecting = false;
+ private boolean mFrozen = false;
+ private LinkedList mOperations;
+ GattOperation mCurrentOperation = null;
+ private Handler mHandler;
+
+ private static final int TRANSPORT_AUTO = 0;
+ private static final int TRANSPORT_BREDR = 1;
+ private static final int TRANSPORT_LE = 2;
+
+ private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+ static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+ static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+ static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+ static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+ static class GattOperation {
+ private enum Operation {
+ CHR_READ,
+ CHR_WRITE,
+ ENABLE_NOTIFICATION
+ }
+
+ Operation mOp;
+ UUID mUuid;
+ byte[] mValue;
+ BluetoothGatt mGatt;
+ boolean mResult = true;
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ }
+
+ private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
+ mGatt = gatt;
+ mOp = operation;
+ mUuid = uuid;
+ mValue = value;
+ }
+
+ public void run() {
+ // This is executed in main thread
+ BluetoothGattCharacteristic chr;
+
+ switch (mOp) {
+ case CHR_READ:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Reading characteristic " + chr.getUuid());
+ if (!mGatt.readCharacteristic(chr)) {
+ Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case CHR_WRITE:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
+ chr.setValue(mValue);
+ if (!mGatt.writeCharacteristic(chr)) {
+ Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
+ mResult = false;
+ break;
+ }
+ mResult = true;
+ break;
+ case ENABLE_NOTIFICATION:
+ chr = getCharacteristic(mUuid);
+ //Log.v(TAG, "Writing descriptor of " + chr.getUuid());
+ if (chr != null) {
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ int properties = chr.getProperties();
+ byte[] value;
+ if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
+ value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
+ } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
+ value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
+ } else {
+ Log.e(TAG, "Unable to start notifications on input characteristic");
+ mResult = false;
+ return;
+ }
+
+ mGatt.setCharacteristicNotification(chr, true);
+ cccd.setValue(value);
+ if (!mGatt.writeDescriptor(cccd)) {
+ Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
+ mResult = false;
+ return;
+ }
+ mResult = true;
+ }
+ }
+ }
+ }
+
+ public boolean finish() {
+ return mResult;
+ }
+
+ private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+ BluetoothGattService valveService = mGatt.getService(steamControllerService);
+ if (valveService == null)
+ return null;
+ return valveService.getCharacteristic(uuid);
+ }
+
+ static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.CHR_READ, uuid);
+ }
+
+ static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
+ return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
+ }
+
+ static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
+ return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
+ }
+ }
+
+ public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+ mManager = manager;
+ mDevice = device;
+ mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+ mIsRegistered = false;
+ mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+ mOperations = new LinkedList();
+ mHandler = new Handler(Looper.getMainLooper());
+
+ mGatt = connectGatt();
+ // final HIDDeviceBLESteamController finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.checkConnectionForChromebookIssue();
+ // }
+ // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ public String getIdentifier() {
+ return String.format("SteamController.%s", mDevice.getAddress());
+ }
+
+ public BluetoothGatt getGatt() {
+ return mGatt;
+ }
+
+ // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
+ // of TRANSPORT_LE. Let's force ourselves to connect low energy.
+ private BluetoothGatt connectGatt(boolean managed) {
+ if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
+ try {
+ return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
+ } catch (Exception e) {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ } else {
+ return mDevice.connectGatt(mManager.getContext(), managed, this);
+ }
+ }
+
+ private BluetoothGatt connectGatt() {
+ return connectGatt(false);
+ }
+
+ protected int getConnectionState() {
+
+ Context context = mManager.getContext();
+ if (context == null) {
+ // We are lacking any context to get our Bluetooth information. We'll just assume disconnected.
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (btManager == null) {
+ // This device doesn't support Bluetooth. We should never be here, because how did
+ // we instantiate a device to start with?
+ return BluetoothProfile.STATE_DISCONNECTED;
+ }
+
+ return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
+ }
+
+ public void reconnect() {
+
+ if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
+ mGatt.disconnect();
+ mGatt = connectGatt();
+ }
+
+ }
+
+ protected void checkConnectionForChromebookIssue() {
+ if (!mIsChromebook) {
+ // We only do this on Chromebooks, because otherwise it's really annoying to just attempt
+ // over and over.
+ return;
+ }
+
+ int connectionState = getConnectionState();
+
+ switch (connectionState) {
+ case BluetoothProfile.STATE_CONNECTED:
+ if (!mIsConnected) {
+ // We are in the Bad Chromebook Place. We can force a disconnect
+ // to try to recover.
+ Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ else if (!isRegistered()) {
+ if (mGatt.getServices().size() > 0) {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover.");
+ probeService(this);
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover.");
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+ }
+ }
+ else {
+ Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!");
+ return;
+ }
+ break;
+
+ case BluetoothProfile.STATE_DISCONNECTED:
+ Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover.");
+
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ break;
+
+ case BluetoothProfile.STATE_CONNECTING:
+ Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer.");
+ break;
+ }
+
+ final HIDDeviceBLESteamController finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.checkConnectionForChromebookIssue();
+ }
+ }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+ }
+
+ private boolean isRegistered() {
+ return mIsRegistered;
+ }
+
+ private void setRegistered() {
+ mIsRegistered = true;
+ }
+
+ private boolean probeService(HIDDeviceBLESteamController controller) {
+
+ if (isRegistered()) {
+ return true;
+ }
+
+ if (!mIsConnected) {
+ return false;
+ }
+
+ Log.v(TAG, "probeService controller=" + controller);
+
+ for (BluetoothGattService service : mGatt.getServices()) {
+ if (service.getUuid().equals(steamControllerService)) {
+ Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
+
+ for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ Log.v(TAG, "Found input characteristic");
+ // Start notifications
+ BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+ if (cccd != null) {
+ enableNotification(chr.getUuid());
+ }
+ }
+ }
+ return true;
+ }
+ }
+
+ if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
+ Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
+ mIsConnected = false;
+ mIsReconnecting = true;
+ mGatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+
+ return false;
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private void finishCurrentGattOperation() {
+ GattOperation op = null;
+ synchronized (mOperations) {
+ if (mCurrentOperation != null) {
+ op = mCurrentOperation;
+ mCurrentOperation = null;
+ }
+ }
+ if (op != null) {
+ boolean result = op.finish(); // TODO: Maybe in main thread as well?
+
+ // Our operation failed, let's add it back to the beginning of our queue.
+ if (!result) {
+ mOperations.addFirst(op);
+ }
+ }
+ executeNextGattOperation();
+ }
+
+ private void executeNextGattOperation() {
+ synchronized (mOperations) {
+ if (mCurrentOperation != null)
+ return;
+
+ if (mOperations.isEmpty())
+ return;
+
+ mCurrentOperation = mOperations.removeFirst();
+ }
+
+ // Run in main thread
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ synchronized (mOperations) {
+ if (mCurrentOperation == null) {
+ Log.e(TAG, "Current operation null in executor?");
+ return;
+ }
+
+ mCurrentOperation.run();
+ // now wait for the GATT callback and when it comes, finish this operation
+ }
+ }
+ });
+ }
+
+ private void queueGattOperation(GattOperation op) {
+ synchronized (mOperations) {
+ mOperations.add(op);
+ }
+ executeNextGattOperation();
+ }
+
+ private void enableNotification(UUID chrUuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
+ queueGattOperation(op);
+ }
+
+ public void writeCharacteristic(UUID uuid, byte[] value) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
+ queueGattOperation(op);
+ }
+
+ public void readCharacteristic(UUID uuid) {
+ GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
+ queueGattOperation(op);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////////// BluetoothGattCallback overridden methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
+ //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
+ mIsReconnecting = false;
+ if (newState == 2) {
+ mIsConnected = true;
+ // Run directly, without GattOperation
+ if (!isRegistered()) {
+ mHandler.post(new Runnable() {
+ @Override
+ public void run() {
+ mGatt.discoverServices();
+ }
+ });
+ }
+ }
+ else if (newState == 0) {
+ mIsConnected = false;
+ }
+
+ // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
+ }
+
+ public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onServicesDiscovered status=" + status);
+ if (status == 0) {
+ if (gatt.getServices().size() == 0) {
+ Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
+ mIsReconnecting = true;
+ mIsConnected = false;
+ gatt.disconnect();
+ mGatt = connectGatt(false);
+ }
+ else {
+ probeService(this);
+ }
+ }
+ }
+
+ public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceReportResponse(getId(), characteristic.getValue());
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+ //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
+
+ if (characteristic.getUuid().equals(reportCharacteristic)) {
+ // Only register controller with the native side once it has been fully configured
+ if (!isRegistered()) {
+ Log.v(TAG, "Registering Steam Controller with ID: " + getId());
+ mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0, true);
+ setRegistered();
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+ // Enable this for verbose logging of controller input reports
+ //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
+
+ if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
+ mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
+ }
+ }
+
+ public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ //Log.v(TAG, "onDescriptorRead status=" + status);
+ }
+
+ public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+ BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
+ //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
+
+ if (chr.getUuid().equals(inputCharacteristic)) {
+ boolean hasWrittenInputDescriptor = true;
+ BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
+ if (reportChr != null) {
+ Log.v(TAG, "Writing report characteristic to enter valve mode");
+ reportChr.setValue(enterValveMode);
+ gatt.writeCharacteristic(reportChr);
+ }
+ }
+
+ finishCurrentGattOperation();
+ }
+
+ public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+ //Log.v(TAG, "onReliableWriteCompleted status=" + status);
+ }
+
+ public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+ //Log.v(TAG, "onReadRemoteRssi status=" + status);
+ }
+
+ public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+ //Log.v(TAG, "onMtuChanged status=" + status);
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////// Public API
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ // Valve Corporation
+ final int VALVE_USB_VID = 0x28DE;
+ return VALVE_USB_VID;
+ }
+
+ @Override
+ public int getProductId() {
+ // We don't have an easy way to query from the Bluetooth device, but we know what it is
+ final int D0G_BLE2_PID = 0x1106;
+ return D0G_BLE2_PID;
+ }
+
+ @Override
+ public String getSerialNumber() {
+ // This will be read later via feature report by Steam
+ return "12345";
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ return "Valve Corporation";
+ }
+
+ @Override
+ public String getProductName() {
+ return "Steam Controller";
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return null;
+ }
+
+ @Override
+ public boolean open() {
+ return true;
+ }
+
+ @Override
+ public int writeReport(byte[] report, boolean feature) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted writeReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return -1;
+ }
+
+ if (feature) {
+ // We need to skip the first byte, as that doesn't go over the air
+ byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+ //Log.v(TAG, "writeFeatureReport " + HexDump.dumpHexString(actual_report));
+ writeCharacteristic(reportCharacteristic, actual_report);
+ return report.length;
+ } else {
+ //Log.v(TAG, "writeOutputReport " + HexDump.dumpHexString(report));
+ writeCharacteristic(reportCharacteristic, report);
+ return report.length;
+ }
+ }
+
+ @Override
+ public boolean readReport(byte[] report, boolean feature) {
+ if (!isRegistered()) {
+ Log.e(TAG, "Attempted readReport before Steam Controller is registered!");
+ if (mIsConnected) {
+ probeService(this);
+ }
+ return false;
+ }
+
+ if (feature) {
+ readCharacteristic(reportCharacteristic);
+ return true;
+ } else {
+ // Not implemented
+ return false;
+ }
+ }
+
+ @Override
+ public void close() {
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+
+ BluetoothGatt g = mGatt;
+ if (g != null) {
+ g.disconnect();
+ g.close();
+ mGatt = null;
+ }
+ mManager = null;
+ mIsRegistered = false;
+ mIsConnected = false;
+ mOperations.clear();
+ }
+
+}
+
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java
new file mode 100644
index 0000000000..27ef9c3e08
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceManager.java
@@ -0,0 +1,690 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.os.Build;
+import android.util.Log;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.hardware.usb.*;
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+
+public class HIDDeviceManager {
+ private static final String TAG = "hidapi";
+ private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
+
+ private static HIDDeviceManager sManager;
+ private static int sManagerRefCount = 0;
+
+ public static HIDDeviceManager acquire(Context context) {
+ if (sManagerRefCount == 0) {
+ sManager = new HIDDeviceManager(context);
+ }
+ ++sManagerRefCount;
+ return sManager;
+ }
+
+ public static void release(HIDDeviceManager manager) {
+ if (manager == sManager) {
+ --sManagerRefCount;
+ if (sManagerRefCount == 0) {
+ sManager.close();
+ sManager = null;
+ }
+ }
+ }
+
+ private Context mContext;
+ private HashMap mDevicesById = new HashMap();
+ private HashMap mBluetoothDevices = new HashMap();
+ private int mNextDeviceId = 0;
+ private SharedPreferences mSharedPreferences = null;
+ private boolean mIsChromebook = false;
+ private UsbManager mUsbManager;
+ private Handler mHandler;
+ private BluetoothManager mBluetoothManager;
+ private List mLastBluetoothDevices;
+
+ private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceAttached(usbDevice);
+ } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDeviceDetached(usbDevice);
+ } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
+ UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+ handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
+ }
+ }
+ };
+
+ private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ String action = intent.getAction();
+ // Bluetooth device was connected. If it was a Steam Controller, handle it
+ if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device connected: " + device);
+
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+ }
+
+ // Bluetooth device was disconnected, remove from controller manager (if any)
+ if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
+ BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+ Log.d(TAG, "Bluetooth device disconnected: " + device);
+
+ disconnectBluetoothDevice(device);
+ }
+ }
+ };
+
+ private HIDDeviceManager(final Context context) {
+ mContext = context;
+
+ HIDDeviceRegisterCallback();
+
+ mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
+ mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+
+// if (shouldClear) {
+// SharedPreferences.Editor spedit = mSharedPreferences.edit();
+// spedit.clear();
+// spedit.commit();
+// }
+// else
+ {
+ mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
+ }
+ }
+
+ public Context getContext() {
+ return mContext;
+ }
+
+ public int getDeviceIDForIdentifier(String identifier) {
+ SharedPreferences.Editor spedit = mSharedPreferences.edit();
+
+ int result = mSharedPreferences.getInt(identifier, 0);
+ if (result == 0) {
+ result = mNextDeviceId++;
+ spedit.putInt("next_device_id", mNextDeviceId);
+ }
+
+ spedit.putInt(identifier, result);
+ spedit.commit();
+ return result;
+ }
+
+ private void initializeUSB() {
+ mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
+ if (mUsbManager == null) {
+ return;
+ }
+
+ /*
+ // Logging
+ for (UsbDevice device : mUsbManager.getDeviceList().values()) {
+ Log.i(TAG,"Path: " + device.getDeviceName());
+ Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
+ Log.i(TAG,"Product: " + device.getProductName());
+ Log.i(TAG,"ID: " + device.getDeviceId());
+ Log.i(TAG,"Class: " + device.getDeviceClass());
+ Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
+ Log.i(TAG,"Vendor ID " + device.getVendorId());
+ Log.i(TAG,"Product ID: " + device.getProductId());
+ Log.i(TAG,"Interface count: " + device.getInterfaceCount());
+ Log.i(TAG,"---------------------------------------");
+
+ // Get interface details
+ for (int index = 0; index < device.getInterfaceCount(); index++) {
+ UsbInterface mUsbInterface = device.getInterface(index);
+ Log.i(TAG," ***** *****");
+ Log.i(TAG," Interface index: " + index);
+ Log.i(TAG," Interface ID: " + mUsbInterface.getId());
+ Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass());
+ Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass());
+ Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol());
+ Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount());
+
+ // Get endpoint details
+ for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
+ {
+ UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
+ Log.i(TAG," ++++ ++++ ++++");
+ Log.i(TAG," Endpoint index: " + epi);
+ Log.i(TAG," Attributes: " + mEndpoint.getAttributes());
+ Log.i(TAG," Direction: " + mEndpoint.getDirection());
+ Log.i(TAG," Number: " + mEndpoint.getEndpointNumber());
+ Log.i(TAG," Interval: " + mEndpoint.getInterval());
+ Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize());
+ Log.i(TAG," Type: " + mEndpoint.getType());
+ }
+ }
+ }
+ Log.i(TAG," No more devices connected.");
+ */
+
+ // Register for USB broadcasts and permission completions
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+ filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+ filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ mContext.registerReceiver(mUsbBroadcast, filter, Context.RECEIVER_EXPORTED);
+ } else {
+ mContext.registerReceiver(mUsbBroadcast, filter);
+ }
+
+ for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
+ handleUsbDeviceAttached(usbDevice);
+ }
+ }
+
+ UsbManager getUSBManager() {
+ return mUsbManager;
+ }
+
+ private void shutdownUSB() {
+ try {
+ mContext.unregisterReceiver(mUsbBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
+ return true;
+ }
+ if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
+ return true;
+ }
+ return false;
+ }
+
+ private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB360_IFACE_SUBCLASS = 93;
+ final int XB360_IFACE_PROTOCOL = 1; // Wired
+ final int XB360W_IFACE_PROTOCOL = 129; // Wireless
+ final int[] SUPPORTED_VENDORS = {
+ 0x0079, // GPD Win 2
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x046d, // Logitech
+ 0x056e, // Elecom
+ 0x06a3, // Saitek
+ 0x0738, // Mad Catz
+ 0x07ff, // Mad Catz
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x1038, // SteelSeries
+ 0x11c9, // Nacon
+ 0x12ab, // Unknown
+ 0x1430, // RedOctane
+ 0x146b, // BigBen
+ 0x1532, // Razer Sabertooth
+ 0x15e4, // Numark
+ 0x162e, // Joytech
+ 0x1689, // Razer Onza
+ 0x1949, // Lab126, Inc.
+ 0x1bad, // Harmonix
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2c22, // Qanba
+ 0x2dc8, // 8BitDo
+ 0x9886, // ASTRO Gaming
+ };
+
+ if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
+ (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
+ usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
+ final int XB1_IFACE_SUBCLASS = 71;
+ final int XB1_IFACE_PROTOCOL = 208;
+ final int[] SUPPORTED_VENDORS = {
+ 0x03f0, // HP
+ 0x044f, // Thrustmaster
+ 0x045e, // Microsoft
+ 0x0738, // Mad Catz
+ 0x0b05, // ASUS
+ 0x0e6f, // PDP
+ 0x0f0d, // Hori
+ 0x10f5, // Turtle Beach
+ 0x1532, // Razer Wildcat
+ 0x20d6, // PowerA
+ 0x24c6, // PowerA
+ 0x2dc8, // 8BitDo
+ 0x2e24, // Hyperkin
+ 0x3537, // GameSir
+ };
+
+ if (usbInterface.getId() == 0 &&
+ usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+ usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
+ usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
+ int vendor_id = usbDevice.getVendorId();
+ for (int supportedVid : SUPPORTED_VENDORS) {
+ if (vendor_id == supportedVid) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private void handleUsbDeviceAttached(UsbDevice usbDevice) {
+ connectHIDDeviceUSB(usbDevice);
+ }
+
+ private void handleUsbDeviceDetached(UsbDevice usbDevice) {
+ List devices = new ArrayList();
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ devices.add(device.getId());
+ }
+ }
+ for (int id : devices) {
+ HIDDevice device = mDevicesById.get(id);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
+ for (HIDDevice device : mDevicesById.values()) {
+ if (usbDevice.equals(device.getDevice())) {
+ boolean opened = false;
+ if (permission_granted) {
+ opened = device.open();
+ }
+ HIDDeviceOpenResult(device.getId(), opened);
+ }
+ }
+ }
+
+ private void connectHIDDeviceUSB(UsbDevice usbDevice) {
+ synchronized (this) {
+ int interface_mask = 0;
+ for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
+ UsbInterface usbInterface = usbDevice.getInterface(interface_index);
+ if (isHIDDeviceInterface(usbDevice, usbInterface)) {
+ // Check to see if we've already added this interface
+ // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
+ int interface_id = usbInterface.getId();
+ if ((interface_mask & (1 << interface_id)) != 0) {
+ continue;
+ }
+ interface_mask |= (1 << interface_id);
+
+ HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
+ int id = device.getId();
+ mDevicesById.put(id, device);
+ HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol(), false);
+ }
+ }
+ }
+ }
+
+ private void initializeBluetooth() {
+ Log.d(TAG, "Initializing Bluetooth");
+
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ &&
+ mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT");
+ return;
+ }
+
+ if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ &&
+ mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
+ return;
+ }
+
+ if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) {
+ Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
+ return;
+ }
+
+ // Find bonded bluetooth controllers and create SteamControllers for them
+ mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+ if (mBluetoothManager == null) {
+ // This device doesn't support Bluetooth.
+ return;
+ }
+
+ BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
+ if (btAdapter == null) {
+ // This device has Bluetooth support in the codebase, but has no available adapters.
+ return;
+ }
+
+ // Get our bonded devices.
+ for (BluetoothDevice device : btAdapter.getBondedDevices()) {
+
+ Log.d(TAG, "Bluetooth device available: " + device);
+ if (isSteamController(device)) {
+ connectBluetoothDevice(device);
+ }
+
+ }
+
+ // NOTE: These don't work on Chromebooks, to my undying dismay.
+ IntentFilter filter = new IntentFilter();
+ filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
+ filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ mContext.registerReceiver(mBluetoothBroadcast, filter, Context.RECEIVER_EXPORTED);
+ } else {
+ mContext.registerReceiver(mBluetoothBroadcast, filter);
+ }
+
+ if (mIsChromebook) {
+ mHandler = new Handler(Looper.getMainLooper());
+ mLastBluetoothDevices = new ArrayList();
+
+ // final HIDDeviceManager finalThis = this;
+ // mHandler.postDelayed(new Runnable() {
+ // @Override
+ // public void run() {
+ // finalThis.chromebookConnectionHandler();
+ // }
+ // }, 5000);
+ }
+ }
+
+ private void shutdownBluetooth() {
+ try {
+ mContext.unregisterReceiver(mBluetoothBroadcast);
+ } catch (Exception e) {
+ // We may not have registered, that's okay
+ }
+ }
+
+ // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
+ // This function provides a sort of dummy version of that, watching for changes in the
+ // connected devices and attempting to add controllers as things change.
+ public void chromebookConnectionHandler() {
+ if (!mIsChromebook) {
+ return;
+ }
+
+ ArrayList disconnected = new ArrayList();
+ ArrayList connected = new ArrayList();
+
+ List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
+
+ for (BluetoothDevice bluetoothDevice : currentConnected) {
+ if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
+ connected.add(bluetoothDevice);
+ }
+ }
+ for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
+ if (!currentConnected.contains(bluetoothDevice)) {
+ disconnected.add(bluetoothDevice);
+ }
+ }
+
+ mLastBluetoothDevices = currentConnected;
+
+ for (BluetoothDevice bluetoothDevice : disconnected) {
+ disconnectBluetoothDevice(bluetoothDevice);
+ }
+ for (BluetoothDevice bluetoothDevice : connected) {
+ connectBluetoothDevice(bluetoothDevice);
+ }
+
+ final HIDDeviceManager finalThis = this;
+ mHandler.postDelayed(new Runnable() {
+ @Override
+ public void run() {
+ finalThis.chromebookConnectionHandler();
+ }
+ }, 10000);
+ }
+
+ public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
+ synchronized (this) {
+ if (mBluetoothDevices.containsKey(bluetoothDevice)) {
+ Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
+
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ device.reconnect();
+
+ return false;
+ }
+ HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
+ int id = device.getId();
+ mBluetoothDevices.put(bluetoothDevice, device);
+ mDevicesById.put(id, device);
+
+ // The Steam Controller will mark itself connected once initialization is complete
+ }
+ return true;
+ }
+
+ public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+ synchronized (this) {
+ HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+ if (device == null)
+ return;
+
+ int id = device.getId();
+ mBluetoothDevices.remove(bluetoothDevice);
+ mDevicesById.remove(id);
+ device.shutdown();
+ HIDDeviceDisconnected(id);
+ }
+ }
+
+ public boolean isSteamController(BluetoothDevice bluetoothDevice) {
+ // Sanity check. If you pass in a null device, by definition it is never a Steam Controller.
+ if (bluetoothDevice == null) {
+ return false;
+ }
+
+ // If the device has no local name, we really don't want to try an equality check against it.
+ if (bluetoothDevice.getName() == null) {
+ return false;
+ }
+
+ return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
+ }
+
+ private void close() {
+ shutdownUSB();
+ shutdownBluetooth();
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.shutdown();
+ }
+ mDevicesById.clear();
+ mBluetoothDevices.clear();
+ HIDDeviceReleaseCallback();
+ }
+ }
+
+ public void setFrozen(boolean frozen) {
+ synchronized (this) {
+ for (HIDDevice device : mDevicesById.values()) {
+ device.setFrozen(frozen);
+ }
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private HIDDevice getDevice(int id) {
+ synchronized (this) {
+ HIDDevice result = mDevicesById.get(id);
+ if (result == null) {
+ Log.v(TAG, "No device for id: " + id);
+ Log.v(TAG, "Available devices: " + mDevicesById.keySet());
+ }
+ return result;
+ }
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ ////////// JNI interface functions
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ public boolean initialize(boolean usb, boolean bluetooth) {
+ Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
+
+ if (usb) {
+ initializeUSB();
+ }
+ if (bluetooth) {
+ initializeBluetooth();
+ }
+ return true;
+ }
+
+ public boolean openDevice(int deviceID) {
+ Log.v(TAG, "openDevice deviceID=" + deviceID);
+ HIDDevice device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ // Look to see if this is a USB device and we have permission to access it
+ UsbDevice usbDevice = device.getDevice();
+ if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
+ HIDDeviceOpenPending(deviceID);
+ try {
+ final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
+ int flags;
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
+ flags = FLAG_MUTABLE;
+ } else {
+ flags = 0;
+ }
+ if (Build.VERSION.SDK_INT >= 33 /* Android 14.0 (U) */) {
+ Intent intent = new Intent(HIDDeviceManager.ACTION_USB_PERMISSION);
+ intent.setPackage(mContext.getPackageName());
+ mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, intent, flags));
+ } else {
+ mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags));
+ }
+ } catch (Exception e) {
+ Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
+ HIDDeviceOpenResult(deviceID, false);
+ }
+ return false;
+ }
+
+ try {
+ return device.open();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ public int writeReport(int deviceID, byte[] report, boolean feature) {
+ try {
+ //Log.v(TAG, "writeReport deviceID=" + deviceID + " length=" + report.length);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return -1;
+ }
+
+ return device.writeReport(report, feature);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return -1;
+ }
+
+ public boolean readReport(int deviceID, byte[] report, boolean feature) {
+ try {
+ //Log.v(TAG, "readReport deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return false;
+ }
+
+ return device.readReport(report, feature);
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ return false;
+ }
+
+ public void closeDevice(int deviceID) {
+ try {
+ Log.v(TAG, "closeDevice deviceID=" + deviceID);
+ HIDDevice device;
+ device = getDevice(deviceID);
+ if (device == null) {
+ HIDDeviceDisconnected(deviceID);
+ return;
+ }
+
+ device.close();
+ } catch (Exception e) {
+ Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+ }
+ }
+
+
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+ /////////////// Native methods
+ //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+ private native void HIDDeviceRegisterCallback();
+ private native void HIDDeviceReleaseCallback();
+
+ native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol, boolean bBluetooth);
+ native void HIDDeviceOpenPending(int deviceID);
+ native void HIDDeviceOpenResult(int deviceID, boolean opened);
+ native void HIDDeviceDisconnected(int deviceID);
+
+ native void HIDDeviceInputReport(int deviceID, byte[] report);
+ native void HIDDeviceReportResponse(int deviceID, byte[] report);
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java
new file mode 100644
index 0000000000..b5de68b686
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDeviceUSB.java
@@ -0,0 +1,323 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.hardware.usb.*;
+import android.os.Build;
+import android.util.Log;
+import java.util.Arrays;
+
+class HIDDeviceUSB implements HIDDevice {
+
+ private static final String TAG = "hidapi";
+
+ protected HIDDeviceManager mManager;
+ protected UsbDevice mDevice;
+ protected int mInterfaceIndex;
+ protected int mInterface;
+ protected int mDeviceId;
+ protected UsbDeviceConnection mConnection;
+ protected UsbEndpoint mInputEndpoint;
+ protected UsbEndpoint mOutputEndpoint;
+ protected InputThread mInputThread;
+ protected boolean mRunning;
+ protected boolean mFrozen;
+
+ public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
+ mManager = manager;
+ mDevice = usbDevice;
+ mInterfaceIndex = interface_index;
+ mInterface = mDevice.getInterface(mInterfaceIndex).getId();
+ mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
+ mRunning = false;
+ }
+
+ public String getIdentifier() {
+ return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
+ }
+
+ @Override
+ public int getId() {
+ return mDeviceId;
+ }
+
+ @Override
+ public int getVendorId() {
+ return mDevice.getVendorId();
+ }
+
+ @Override
+ public int getProductId() {
+ return mDevice.getProductId();
+ }
+
+ @Override
+ public String getSerialNumber() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ try {
+ result = mDevice.getSerialNumber();
+ }
+ catch (SecurityException exception) {
+ //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
+ }
+ }
+ if (result == null) {
+ result = "";
+ }
+ return result;
+ }
+
+ @Override
+ public int getVersion() {
+ return 0;
+ }
+
+ @Override
+ public String getManufacturerName() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ result = mDevice.getManufacturerName();
+ }
+ if (result == null) {
+ result = String.format("%x", getVendorId());
+ }
+ return result;
+ }
+
+ @Override
+ public String getProductName() {
+ String result = null;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ result = mDevice.getProductName();
+ }
+ if (result == null) {
+ result = String.format("%x", getProductId());
+ }
+ return result;
+ }
+
+ @Override
+ public UsbDevice getDevice() {
+ return mDevice;
+ }
+
+ public String getDeviceName() {
+ return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
+ }
+
+ @Override
+ public boolean open() {
+ mConnection = mManager.getUSBManager().openDevice(mDevice);
+ if (mConnection == null) {
+ Log.w(TAG, "Unable to open USB device " + getDeviceName());
+ return false;
+ }
+
+ // Force claim our interface
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ if (!mConnection.claimInterface(iface, true)) {
+ Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
+ close();
+ return false;
+ }
+
+ // Find the endpoints
+ for (int j = 0; j < iface.getEndpointCount(); j++) {
+ UsbEndpoint endpt = iface.getEndpoint(j);
+ switch (endpt.getDirection()) {
+ case UsbConstants.USB_DIR_IN:
+ if (mInputEndpoint == null) {
+ mInputEndpoint = endpt;
+ }
+ break;
+ case UsbConstants.USB_DIR_OUT:
+ if (mOutputEndpoint == null) {
+ mOutputEndpoint = endpt;
+ }
+ break;
+ }
+ }
+
+ // Make sure the required endpoints were present
+ if (mInputEndpoint == null || mOutputEndpoint == null) {
+ Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
+ close();
+ return false;
+ }
+
+ // Start listening for input
+ mRunning = true;
+ mInputThread = new InputThread();
+ mInputThread.start();
+
+ return true;
+ }
+
+ @Override
+ public int writeReport(byte[] report, boolean feature) {
+ if (mConnection == null) {
+ Log.w(TAG, "writeReport() called with no device connection");
+ return -1;
+ }
+
+ if (feature) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (report_number == 0x0) {
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
+ 0x09/*HID set_report*/,
+ (3/*HID feature*/ << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "writeFeatureReport() returned " + res + " on device " + getDeviceName());
+ return -1;
+ }
+
+ if (skipped_report_id) {
+ ++length;
+ }
+ return length;
+ } else {
+ int res = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
+ if (res != report.length) {
+ Log.w(TAG, "writeOutputReport() returned " + res + " on device " + getDeviceName());
+ }
+ return res;
+ }
+ }
+
+ @Override
+ public boolean readReport(byte[] report, boolean feature) {
+ int res = -1;
+ int offset = 0;
+ int length = report.length;
+ boolean skipped_report_id = false;
+ byte report_number = report[0];
+
+ if (mConnection == null) {
+ Log.w(TAG, "readReport() called with no device connection");
+ return false;
+ }
+
+ if (report_number == 0x0) {
+ /* Offset the return buffer by 1, so that the report ID
+ will remain in byte 0. */
+ ++offset;
+ --length;
+ skipped_report_id = true;
+ }
+
+ res = mConnection.controlTransfer(
+ UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
+ 0x01/*HID get_report*/,
+ ((feature ? 3/*HID feature*/ : 1/*HID Input*/) << 8) | report_number,
+ mInterface,
+ report, offset, length,
+ 1000/*timeout millis*/);
+
+ if (res < 0) {
+ Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
+ return false;
+ }
+
+ if (skipped_report_id) {
+ ++res;
+ ++length;
+ }
+
+ byte[] data;
+ if (res == length) {
+ data = report;
+ } else {
+ data = Arrays.copyOfRange(report, 0, res);
+ }
+ mManager.HIDDeviceReportResponse(mDeviceId, data);
+
+ return true;
+ }
+
+ @Override
+ public void close() {
+ mRunning = false;
+ if (mInputThread != null) {
+ while (mInputThread.isAlive()) {
+ mInputThread.interrupt();
+ try {
+ mInputThread.join();
+ } catch (InterruptedException e) {
+ // Keep trying until we're done
+ }
+ }
+ mInputThread = null;
+ }
+ if (mConnection != null) {
+ UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+ mConnection.releaseInterface(iface);
+ mConnection.close();
+ mConnection = null;
+ }
+ }
+
+ @Override
+ public void shutdown() {
+ close();
+ mManager = null;
+ }
+
+ @Override
+ public void setFrozen(boolean frozen) {
+ mFrozen = frozen;
+ }
+
+ protected class InputThread extends Thread {
+ @Override
+ public void run() {
+ int packetSize = mInputEndpoint.getMaxPacketSize();
+ byte[] packet = new byte[packetSize];
+ while (mRunning) {
+ int r;
+ try
+ {
+ r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
+ }
+ catch (Exception e)
+ {
+ Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
+ break;
+ }
+ if (r < 0) {
+ // Could be a timeout or an I/O error
+ }
+ if (r > 0) {
+ byte[] data;
+ if (r == packetSize) {
+ data = packet;
+ } else {
+ data = Arrays.copyOfRange(packet, 0, r);
+ }
+
+ if (!mFrozen) {
+ mManager.HIDDeviceInputReport(mDeviceId, data);
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java
new file mode 100644
index 0000000000..ffd1e78964
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDL.java
@@ -0,0 +1,95 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.content.Context;
+
+import java.lang.Class;
+import java.lang.reflect.Method;
+
+/**
+ SDL library initialization
+*/
+public class SDL {
+
+ // This function should be called first and sets up the native code
+ // so it can call into the Java classes
+ public static void setupJNI() {
+ SDLActivity.nativeSetupJNI();
+ SDLAudioManager.nativeSetupJNI();
+ SDLControllerManager.nativeSetupJNI();
+ }
+
+ // This function should be called each time the activity is started
+ public static void initialize() {
+ setContext(null);
+
+ SDLActivity.initialize();
+ SDLAudioManager.initialize();
+ SDLControllerManager.initialize();
+ }
+
+ // This function stores the current activity (SDL or not)
+ public static void setContext(Context context) {
+ SDLAudioManager.setContext(context);
+ mContext = context;
+ }
+
+ public static Context getContext() {
+ return mContext;
+ }
+
+ public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+ loadLibrary(libraryName, mContext);
+ }
+
+ public static void loadLibrary(String libraryName, Context context) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+
+ if (libraryName == null) {
+ throw new NullPointerException("No library name provided.");
+ }
+
+ try {
+ // Let's see if we have ReLinker available in the project. This is necessary for
+ // some projects that have huge numbers of local libraries bundled, and thus may
+ // trip a bug in Android's native library loader which ReLinker works around. (If
+ // loadLibrary works properly, ReLinker will simply use the normal Android method
+ // internally.)
+ //
+ // To use ReLinker, just add it as a dependency. For more information, see
+ // https://github.com/KeepSafe/ReLinker for ReLinker's repository.
+ //
+ Class> relinkClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
+ Class> relinkListenerClass = context.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
+ Class> contextClass = context.getClassLoader().loadClass("android.content.Context");
+ Class> stringClass = context.getClassLoader().loadClass("java.lang.String");
+
+ // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if
+ // they've changed during updates.
+ Method forceMethod = relinkClass.getDeclaredMethod("force");
+ Object relinkInstance = forceMethod.invoke(null);
+ Class> relinkInstanceClass = relinkInstance.getClass();
+
+ // Actually load the library!
+ Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
+ loadMethod.invoke(relinkInstance, context, libraryName, null, null);
+ }
+ catch (final Throwable e) {
+ // Fall back
+ try {
+ System.loadLibrary(libraryName);
+ }
+ catch (final UnsatisfiedLinkError ule) {
+ throw ule;
+ }
+ catch (final SecurityException se) {
+ throw se;
+ }
+ }
+ }
+
+ protected static Context mContext;
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java
new file mode 100644
index 0000000000..a6096a0cc5
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLActivity.java
@@ -0,0 +1,2233 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.UiModeManager;
+import android.content.ActivityNotFoundException;
+import android.content.ClipboardManager;
+import android.content.ClipData;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.hardware.Sensor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.LocaleList;
+import android.os.Message;
+import android.os.ParcelFileDescriptor;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.webkit.MimeTypeMap;
+import android.widget.Button;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.io.FileNotFoundException;
+import java.util.ArrayList;
+import java.util.Hashtable;
+import java.util.Locale;
+
+
+/**
+ SDL Activity
+*/
+public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener {
+ private static final String TAG = "SDL";
+ private static final int SDL_MAJOR_VERSION = 3;
+ private static final int SDL_MINOR_VERSION = 2;
+ private static final int SDL_MICRO_VERSION = 20;
+/*
+ // Display InputType.SOURCE/CLASS of events and devices
+ //
+ // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]");
+ // SDLActivity.debugSource(event.getSource(), "event");
+ public static void debugSource(int sources, String prefix) {
+ int s = sources;
+ int s_copy = sources;
+ String cls = "";
+ String src = "";
+ int tst = 0;
+ int FLAG_TAINTED = 0x80000000;
+
+ if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON";
+ if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK";
+ if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER";
+ if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION";
+ if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL";
+
+
+ int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits
+ s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON
+ | InputDevice.SOURCE_CLASS_JOYSTICK
+ | InputDevice.SOURCE_CLASS_POINTER
+ | InputDevice.SOURCE_CLASS_POSITION
+ | InputDevice.SOURCE_CLASS_TRACKBALL);
+
+ if (s2 != 0) cls += "Some_Unknown";
+
+ s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class;
+
+ if (Build.VERSION.SDK_INT >= 23) {
+ tst = InputDevice.SOURCE_BLUETOOTH_STYLUS;
+ if ((s & tst) == tst) src += " BLUETOOTH_STYLUS";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_DPAD;
+ if ((s & tst) == tst) src += " DPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_GAMEPAD;
+ if ((s & tst) == tst) src += " GAMEPAD";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 21) {
+ tst = InputDevice.SOURCE_HDMI;
+ if ((s & tst) == tst) src += " HDMI";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_JOYSTICK;
+ if ((s & tst) == tst) src += " JOYSTICK";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_KEYBOARD;
+ if ((s & tst) == tst) src += " KEYBOARD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_MOUSE;
+ if ((s & tst) == tst) src += " MOUSE";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 26) {
+ tst = InputDevice.SOURCE_MOUSE_RELATIVE;
+ if ((s & tst) == tst) src += " MOUSE_RELATIVE";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ROTARY_ENCODER;
+ if ((s & tst) == tst) src += " ROTARY_ENCODER";
+ s2 &= ~tst;
+ }
+ tst = InputDevice.SOURCE_STYLUS;
+ if ((s & tst) == tst) src += " STYLUS";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHPAD;
+ if ((s & tst) == tst) src += " TOUCHPAD";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_TOUCHSCREEN;
+ if ((s & tst) == tst) src += " TOUCHSCREEN";
+ s2 &= ~tst;
+
+ if (Build.VERSION.SDK_INT >= 18) {
+ tst = InputDevice.SOURCE_TOUCH_NAVIGATION;
+ if ((s & tst) == tst) src += " TOUCH_NAVIGATION";
+ s2 &= ~tst;
+ }
+
+ tst = InputDevice.SOURCE_TRACKBALL;
+ if ((s & tst) == tst) src += " TRACKBALL";
+ s2 &= ~tst;
+
+ tst = InputDevice.SOURCE_ANY;
+ if ((s & tst) == tst) src += " ANY";
+ s2 &= ~tst;
+
+ if (s == FLAG_TAINTED) src += " FLAG_TAINTED";
+ s2 &= ~FLAG_TAINTED;
+
+ if (s2 != 0) src += " Some_Unknown";
+
+ Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src);
+ }
+*/
+
+ public static boolean mIsResumedCalled, mHasFocus;
+ public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */);
+
+ // Cursor types
+ // private static final int SDL_SYSTEM_CURSOR_NONE = -1;
+ private static final int SDL_SYSTEM_CURSOR_ARROW = 0;
+ private static final int SDL_SYSTEM_CURSOR_IBEAM = 1;
+ private static final int SDL_SYSTEM_CURSOR_WAIT = 2;
+ private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3;
+ private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4;
+ private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5;
+ private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6;
+ private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7;
+ private static final int SDL_SYSTEM_CURSOR_SIZENS = 8;
+ private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9;
+ private static final int SDL_SYSTEM_CURSOR_NO = 10;
+ private static final int SDL_SYSTEM_CURSOR_HAND = 11;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT = 12;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_TOP = 13;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT = 14;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_RIGHT = 15;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT = 16;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOM = 17;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT = 18;
+ private static final int SDL_SYSTEM_CURSOR_WINDOW_LEFT = 19;
+
+ protected static final int SDL_ORIENTATION_UNKNOWN = 0;
+ protected static final int SDL_ORIENTATION_LANDSCAPE = 1;
+ protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2;
+ protected static final int SDL_ORIENTATION_PORTRAIT = 3;
+ protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4;
+
+ protected static int mCurrentRotation;
+ protected static Locale mCurrentLocale;
+
+ // Handle the state of the native layer
+ public enum NativeState {
+ INIT, RESUMED, PAUSED
+ }
+
+ public static NativeState mNextNativeState;
+ public static NativeState mCurrentNativeState;
+
+ /** If shared libraries (e.g. SDL or the native application) could not be loaded. */
+ public static boolean mBrokenLibraries = true;
+
+ // Main components
+ protected static SDLActivity mSingleton;
+ protected static SDLSurface mSurface;
+ protected static SDLDummyEdit mTextEdit;
+ protected static boolean mScreenKeyboardShown;
+ protected static ViewGroup mLayout;
+ protected static SDLClipboardHandler mClipboardHandler;
+ protected static Hashtable mCursors;
+ protected static int mLastCursorID;
+ protected static SDLGenericMotionListener_API14 mMotionListener;
+ protected static HIDDeviceManager mHIDDeviceManager;
+
+ // This is what SDL runs in. It invokes SDL_main(), eventually
+ protected static Thread mSDLThread;
+ protected static boolean mSDLMainFinished = false;
+ protected static boolean mActivityCreated = false;
+ private static SDLFileDialogState mFileDialogState = null;
+ protected static boolean mDispatchingKeyEvent = false;
+
+ protected static SDLGenericMotionListener_API14 getMotionListener() {
+ if (mMotionListener == null) {
+ if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
+ mMotionListener = new SDLGenericMotionListener_API26();
+ } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ mMotionListener = new SDLGenericMotionListener_API24();
+ } else {
+ mMotionListener = new SDLGenericMotionListener_API14();
+ }
+ }
+
+ return mMotionListener;
+ }
+
+ /**
+ * The application entry point, called on a dedicated thread (SDLThread).
+ * The default implementation uses the getMainSharedObject() and getMainFunction() methods
+ * to invoke native code from the specified shared library.
+ * It can be overridden by derived classes.
+ */
+ protected void main() {
+ String library = SDLActivity.mSingleton.getMainSharedObject();
+ String function = SDLActivity.mSingleton.getMainFunction();
+ String[] arguments = SDLActivity.mSingleton.getArguments();
+
+ Log.v("SDL", "Running main function " + function + " from library " + library);
+ SDLActivity.nativeRunMain(library, function, arguments);
+ Log.v("SDL", "Finished main function");
+ }
+
+ /**
+ * This method returns the name of the shared object with the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainSharedObject() {
+ String library;
+ String[] libraries = SDLActivity.mSingleton.getLibraries();
+ if (libraries.length > 0) {
+ library = "lib" + libraries[libraries.length - 1] + ".so";
+ } else {
+ library = "libmain.so";
+ }
+ return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
+ }
+
+ /**
+ * This method returns the name of the application entry point
+ * It can be overridden by derived classes.
+ */
+ protected String getMainFunction() {
+ return "SDL_main";
+ }
+
+ /**
+ * This method is called by SDL before loading the native shared libraries.
+ * It can be overridden to provide names of shared libraries to be loaded.
+ * The default implementation returns the defaults. It never returns null.
+ * An array returned by a new implementation must at least contain "SDL3".
+ * Also keep in mind that the order the libraries are loaded may matter.
+ * @return names of shared libraries to be loaded (e.g. "SDL3", "main").
+ */
+ protected String[] getLibraries() {
+ return new String[] {
+ "SDL3",
+ // "SDL3_image",
+ // "SDL3_mixer",
+ // "SDL3_net",
+ // "SDL3_ttf",
+ "main"
+ };
+ }
+
+ // Load the .so
+ public void loadLibraries() {
+ for (String lib : getLibraries()) {
+ SDL.loadLibrary(lib, this);
+ }
+ }
+
+ /**
+ * This method is called by SDL before starting the native application thread.
+ * It can be overridden to provide the arguments after the application name.
+ * The default implementation returns an empty array. It never returns null.
+ * @return arguments for the native application.
+ */
+ protected String[] getArguments() {
+ return new String[0];
+ }
+
+ public static void initialize() {
+ // The static nature of the singleton and Android quirkyness force us to initialize everything here
+ // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values
+ mSingleton = null;
+ mSurface = null;
+ mTextEdit = null;
+ mLayout = null;
+ mClipboardHandler = null;
+ mCursors = new Hashtable();
+ mLastCursorID = 0;
+ mSDLThread = null;
+ mIsResumedCalled = false;
+ mHasFocus = true;
+ mNextNativeState = NativeState.INIT;
+ mCurrentNativeState = NativeState.INIT;
+ }
+
+ protected SDLSurface createSDLSurface(Context context) {
+ return new SDLSurface(context);
+ }
+
+ // Setup
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ Log.v(TAG, "Manufacturer: " + Build.MANUFACTURER);
+ Log.v(TAG, "Device: " + Build.DEVICE);
+ Log.v(TAG, "Model: " + Build.MODEL);
+ Log.v(TAG, "onCreate()");
+ super.onCreate(savedInstanceState);
+
+
+ /* Control activity re-creation */
+ if (mSDLMainFinished || mActivityCreated) {
+ boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity();
+ if (mSDLMainFinished) {
+ Log.v(TAG, "SDL main() finished");
+ }
+ if (allow_recreate) {
+ Log.v(TAG, "activity re-created");
+ } else {
+ Log.v(TAG, "activity finished");
+ System.exit(0);
+ return;
+ }
+ }
+
+ mActivityCreated = true;
+
+ try {
+ Thread.currentThread().setName("SDLActivity");
+ } catch (Exception e) {
+ Log.v(TAG, "modify thread properties failed " + e.toString());
+ }
+
+ // Load shared libraries
+ String errorMsgBrokenLib = "";
+ try {
+ loadLibraries();
+ mBrokenLibraries = false; /* success */
+ } catch(UnsatisfiedLinkError e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ } catch(Exception e) {
+ System.err.println(e.getMessage());
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = e.getMessage();
+ }
+
+ if (!mBrokenLibraries) {
+ String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." +
+ String.valueOf(SDL_MINOR_VERSION) + "." +
+ String.valueOf(SDL_MICRO_VERSION);
+ String version = nativeGetVersion();
+ if (!version.equals(expected_version)) {
+ mBrokenLibraries = true;
+ errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")";
+ }
+ }
+
+ if (mBrokenLibraries) {
+ mSingleton = this;
+ AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this);
+ dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall."
+ + System.getProperty("line.separator")
+ + System.getProperty("line.separator")
+ + "Error: " + errorMsgBrokenLib);
+ dlgAlert.setTitle("SDL Error");
+ dlgAlert.setPositiveButton("Exit",
+ new DialogInterface.OnClickListener() {
+ @Override
+ public void onClick(DialogInterface dialog,int id) {
+ // if this button is clicked, close current activity
+ SDLActivity.mSingleton.finish();
+ }
+ });
+ dlgAlert.setCancelable(false);
+ dlgAlert.create().show();
+
+ return;
+ }
+
+
+ /* Control activity re-creation */
+ /* Robustness: check that the native code is run for the first time.
+ * (Maybe Activity was reset, but not the native code.) */
+ {
+ int run_count = SDLActivity.nativeCheckSDLThreadCounter(); /* get and increment a native counter */
+ if (run_count != 0) {
+ boolean allow_recreate = SDLActivity.nativeAllowRecreateActivity();
+ if (allow_recreate) {
+ Log.v(TAG, "activity re-created // run_count: " + run_count);
+ } else {
+ Log.v(TAG, "activity finished // run_count: " + run_count);
+ System.exit(0);
+ return;
+ }
+ }
+ }
+
+ // Set up JNI
+ SDL.setupJNI();
+
+ // Initialize state
+ SDL.initialize();
+
+ // So we can call stuff from static callbacks
+ mSingleton = this;
+ SDL.setContext(this);
+
+ mClipboardHandler = new SDLClipboardHandler();
+
+ mHIDDeviceManager = HIDDeviceManager.acquire(this);
+
+ // Set up the surface
+ mSurface = createSDLSurface(this);
+
+ mLayout = new RelativeLayout(this);
+ mLayout.addView(mSurface);
+
+ // Get our current screen orientation and pass it down.
+ SDLActivity.nativeSetNaturalOrientation(SDLActivity.getNaturalOrientation());
+ mCurrentRotation = SDLActivity.getCurrentRotation();
+ SDLActivity.onNativeRotationChanged(mCurrentRotation);
+
+ try {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ mCurrentLocale = getContext().getResources().getConfiguration().locale;
+ } else {
+ mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0);
+ }
+ } catch(Exception ignored) {
+ }
+
+ switch (getContext().getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK) {
+ case Configuration.UI_MODE_NIGHT_NO:
+ SDLActivity.onNativeDarkModeChanged(false);
+ break;
+ case Configuration.UI_MODE_NIGHT_YES:
+ SDLActivity.onNativeDarkModeChanged(true);
+ break;
+ }
+
+ setContentView(mLayout);
+
+ setWindowStyle(false);
+
+ getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this);
+
+ // Get filename from "Open with" of another application
+ Intent intent = getIntent();
+ if (intent != null && intent.getData() != null) {
+ String filename = intent.getData().getPath();
+ if (filename != null) {
+ Log.v(TAG, "Got filename: " + filename);
+ SDLActivity.onNativeDropFile(filename);
+ }
+ }
+ }
+
+ protected void pauseNativeThread() {
+ mNextNativeState = NativeState.PAUSED;
+ mIsResumedCalled = false;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ protected void resumeNativeThread() {
+ mNextNativeState = NativeState.RESUMED;
+ mIsResumedCalled = true;
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.handleNativeState();
+ }
+
+ // Events
+ @Override
+ protected void onPause() {
+ Log.v(TAG, "onPause()");
+ super.onPause();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(true);
+ }
+ if (!mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onResume() {
+ Log.v(TAG, "onResume()");
+ super.onResume();
+
+ if (mHIDDeviceManager != null) {
+ mHIDDeviceManager.setFrozen(false);
+ }
+ if (!mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStop() {
+ Log.v(TAG, "onStop()");
+ super.onStop();
+ if (mHasMultiWindow) {
+ pauseNativeThread();
+ }
+ }
+
+ @Override
+ protected void onStart() {
+ Log.v(TAG, "onStart()");
+ super.onStart();
+ if (mHasMultiWindow) {
+ resumeNativeThread();
+ }
+ }
+
+ public static int getNaturalOrientation() {
+ int result = SDL_ORIENTATION_UNKNOWN;
+
+ Activity activity = (Activity)getContext();
+ if (activity != null) {
+ Configuration config = activity.getResources().getConfiguration();
+ Display display = activity.getWindowManager().getDefaultDisplay();
+ int rotation = display.getRotation();
+ if (((rotation == Surface.ROTATION_0 || rotation == Surface.ROTATION_180) &&
+ config.orientation == Configuration.ORIENTATION_LANDSCAPE) ||
+ ((rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270) &&
+ config.orientation == Configuration.ORIENTATION_PORTRAIT)) {
+ result = SDL_ORIENTATION_LANDSCAPE;
+ } else {
+ result = SDL_ORIENTATION_PORTRAIT;
+ }
+ }
+ return result;
+ }
+
+ public static int getCurrentRotation() {
+ int result = 0;
+
+ Activity activity = (Activity)getContext();
+ if (activity != null) {
+ Display display = activity.getWindowManager().getDefaultDisplay();
+ switch (display.getRotation()) {
+ case Surface.ROTATION_0:
+ result = 0;
+ break;
+ case Surface.ROTATION_90:
+ result = 90;
+ break;
+ case Surface.ROTATION_180:
+ result = 180;
+ break;
+ case Surface.ROTATION_270:
+ result = 270;
+ break;
+ }
+ }
+ return result;
+ }
+
+ @Override
+ public void onWindowFocusChanged(boolean hasFocus) {
+ super.onWindowFocusChanged(hasFocus);
+ Log.v(TAG, "onWindowFocusChanged(): " + hasFocus);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ mHasFocus = hasFocus;
+ if (hasFocus) {
+ mNextNativeState = NativeState.RESUMED;
+ SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded();
+
+ SDLActivity.handleNativeState();
+ nativeFocusChanged(true);
+
+ } else {
+ nativeFocusChanged(false);
+ if (!mHasMultiWindow) {
+ mNextNativeState = NativeState.PAUSED;
+ SDLActivity.handleNativeState();
+ }
+ }
+ }
+
+ @Override
+ public void onTrimMemory(int level) {
+ Log.v(TAG, "onTrimMemory()");
+ super.onTrimMemory(level);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ SDLActivity.nativeLowMemory();
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig) {
+ Log.v(TAG, "onConfigurationChanged()");
+ super.onConfigurationChanged(newConfig);
+
+ if (SDLActivity.mBrokenLibraries) {
+ return;
+ }
+
+ if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) {
+ mCurrentLocale = newConfig.locale;
+ SDLActivity.onNativeLocaleChanged();
+ }
+
+ switch (newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK) {
+ case Configuration.UI_MODE_NIGHT_NO:
+ SDLActivity.onNativeDarkModeChanged(false);
+ break;
+ case Configuration.UI_MODE_NIGHT_YES:
+ SDLActivity.onNativeDarkModeChanged(true);
+ break;
+ }
+ }
+
+ @Override
+ protected void onDestroy() {
+ Log.v(TAG, "onDestroy()");
+
+ if (mHIDDeviceManager != null) {
+ HIDDeviceManager.release(mHIDDeviceManager);
+ mHIDDeviceManager = null;
+ }
+
+ SDLAudioManager.release(this);
+
+ if (SDLActivity.mBrokenLibraries) {
+ super.onDestroy();
+ return;
+ }
+
+ if (SDLActivity.mSDLThread != null) {
+
+ // Send Quit event to "SDLThread" thread
+ SDLActivity.nativeSendQuit();
+
+ // Wait for "SDLThread" thread to end
+ try {
+ // Use a timeout because:
+ // C SDLmain() thread might have started (mSDLThread.start() called)
+ // while the SDL_Init() might not have been called yet,
+ // and so the previous QUIT event will be discarded by SDL_Init() and app is running, not exiting.
+ SDLActivity.mSDLThread.join(1000);
+ } catch(Exception e) {
+ Log.v(TAG, "Problem stopping SDLThread: " + e);
+ }
+ }
+
+ SDLActivity.nativeQuit();
+
+ super.onDestroy();
+ }
+
+ @Override
+ public void onBackPressed() {
+ // Check if we want to block the back button in case of mouse right click.
+ //
+ // If we do, the normal hardware back button will no longer work and people have to use home,
+ // but the mouse right click will work.
+ //
+ boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false);
+ if (trapBack) {
+ // Exit and let the mouse handler handle this button (if appropriate)
+ return;
+ }
+
+ // Default system back button behavior.
+ if (!isFinishing()) {
+ super.onBackPressed();
+ }
+ }
+
+ @Override
+ protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+
+ if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) {
+ /* This is our file dialog */
+ String[] filelist = null;
+
+ if (data != null) {
+ Uri singleFileUri = data.getData();
+
+ if (singleFileUri == null) {
+ /* Use Intent.getClipData to get multiple choices */
+ ClipData clipData = data.getClipData();
+ assert clipData != null;
+
+ filelist = new String[clipData.getItemCount()];
+
+ for (int i = 0; i < filelist.length; i++) {
+ String uri = clipData.getItemAt(i).getUri().toString();
+ filelist[i] = uri;
+ }
+ } else {
+ /* Only one file is selected. */
+ filelist = new String[]{singleFileUri.toString()};
+ }
+ } else {
+ /* User cancelled the request. */
+ filelist = new String[0];
+ }
+
+ // TODO: Detect the file MIME type and pass the filter value accordingly.
+ SDLActivity.onNativeFileDialog(requestCode, filelist, -1);
+ mFileDialogState = null;
+ }
+ }
+
+ // Called by JNI from SDL.
+ public static void manualBackButton() {
+ mSingleton.pressBackButton();
+ }
+
+ // Used to get us onto the activity's main thread
+ public void pressBackButton() {
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ if (!SDLActivity.this.isFinishing()) {
+ SDLActivity.this.superOnBackPressed();
+ }
+ }
+ });
+ }
+
+ // Used to access the system back behavior.
+ public void superOnBackPressed() {
+ super.onBackPressed();
+ }
+
+ @Override
+ public boolean dispatchKeyEvent(KeyEvent event) {
+
+ if (SDLActivity.mBrokenLibraries) {
+ return false;
+ }
+
+ int keyCode = event.getKeyCode();
+ // Ignore certain special keys so they're handled by Android
+ if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
+ keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+ keyCode == KeyEvent.KEYCODE_CAMERA ||
+ keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */
+ keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */
+ ) {
+ return false;
+ }
+ mDispatchingKeyEvent = true;
+ boolean result = super.dispatchKeyEvent(event);
+ mDispatchingKeyEvent = false;
+ return result;
+ }
+
+ public static boolean dispatchingKeyEvent() {
+ return mDispatchingKeyEvent;
+ }
+
+ /* Transition to next state */
+ public static void handleNativeState() {
+
+ if (mNextNativeState == mCurrentNativeState) {
+ // Already in same state, discard.
+ return;
+ }
+
+ // Try a transition to init state
+ if (mNextNativeState == NativeState.INIT) {
+
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to paused state
+ if (mNextNativeState == NativeState.PAUSED) {
+ if (mSDLThread != null) {
+ nativePause();
+ }
+ if (mSurface != null) {
+ mSurface.handlePause();
+ }
+ mCurrentNativeState = mNextNativeState;
+ return;
+ }
+
+ // Try a transition to resumed state
+ if (mNextNativeState == NativeState.RESUMED) {
+ if (mSurface.mIsSurfaceReady && (mHasFocus || mHasMultiWindow) && mIsResumedCalled) {
+ if (mSDLThread == null) {
+ // This is the entry point to the C app.
+ // Start up the C app thread and enable sensor input for the first time
+ // FIXME: Why aren't we enabling sensor input at start?
+
+ mSDLThread = new Thread(new SDLMain(), "SDLThread");
+ mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+ mSDLThread.start();
+
+ // No nativeResume(), don't signal Android_ResumeSem
+ } else {
+ nativeResume();
+ }
+ mSurface.handleResume();
+
+ mCurrentNativeState = mNextNativeState;
+ }
+ }
+ }
+
+ // Messages from the SDLMain thread
+ protected static final int COMMAND_CHANGE_TITLE = 1;
+ protected static final int COMMAND_CHANGE_WINDOW_STYLE = 2;
+ protected static final int COMMAND_TEXTEDIT_HIDE = 3;
+ protected static final int COMMAND_SET_KEEP_SCREEN_ON = 5;
+ protected static final int COMMAND_USER = 0x8000;
+
+ protected static boolean mFullscreenModeActive;
+
+ /**
+ * This method is called by SDL if SDL did not handle a message itself.
+ * This happens if a received message contains an unsupported command.
+ * Method can be overwritten to handle Messages in a different class.
+ * @param command the command of the message.
+ * @param param the parameter of the message. May be null.
+ * @return if the message was handled in overridden method.
+ */
+ protected boolean onUnhandledMessage(int command, Object param) {
+ return false;
+ }
+
+ /**
+ * A Handler class for Messages from native SDL applications.
+ * It uses current Activities as target (e.g. for the title).
+ * static to prevent implicit references to enclosing object.
+ */
+ protected static class SDLCommandHandler extends Handler {
+ @Override
+ public void handleMessage(Message msg) {
+ Context context = SDL.getContext();
+ if (context == null) {
+ Log.e(TAG, "error handling message, getContext() returned null");
+ return;
+ }
+ switch (msg.arg1) {
+ case COMMAND_CHANGE_TITLE:
+ if (context instanceof Activity) {
+ ((Activity) context).setTitle((String)msg.obj);
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ break;
+ case COMMAND_CHANGE_WINDOW_STYLE:
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ SDLActivity.mFullscreenModeActive = true;
+ } else {
+ int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE;
+ window.getDecorView().setSystemUiVisibility(flags);
+ window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+ window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+ SDLActivity.mFullscreenModeActive = false;
+ }
+ if (Build.VERSION.SDK_INT >= 28 /* Android 9 (Pie) */) {
+ window.getAttributes().layoutInDisplayCutoutMode = WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS;
+ }
+ if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */ &&
+ Build.VERSION.SDK_INT < 35 /* Android 15 */) {
+ SDLActivity.onNativeInsetsChanged(0, 0, 0, 0);
+ }
+ }
+ } else {
+ Log.e(TAG, "error handling message, getContext() returned no Activity");
+ }
+ }
+ break;
+ case COMMAND_TEXTEDIT_HIDE:
+ if (mTextEdit != null) {
+ // Note: On some devices setting view to GONE creates a flicker in landscape.
+ // Setting the View's sizes to 0 is similar to GONE but without the flicker.
+ // The sizes will be set to useful values when the keyboard is shown again.
+ mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0));
+
+ InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0);
+
+ mScreenKeyboardShown = false;
+
+ mSurface.requestFocus();
+ }
+ break;
+ case COMMAND_SET_KEEP_SCREEN_ON:
+ {
+ if (context instanceof Activity) {
+ Window window = ((Activity) context).getWindow();
+ if (window != null) {
+ if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+ window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ } else {
+ window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+ }
+ }
+ }
+ break;
+ }
+ default:
+ if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) {
+ Log.e(TAG, "error handling message, command is " + msg.arg1);
+ }
+ }
+ }
+ }
+
+ // Handler for the messages
+ Handler commandHandler = new SDLCommandHandler();
+
+ // Send a message from the SDLMain thread
+ protected boolean sendCommand(int command, Object data) {
+ Message msg = commandHandler.obtainMessage();
+ msg.arg1 = command;
+ msg.obj = data;
+ boolean result = commandHandler.sendMessage(msg);
+
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ if (command == COMMAND_CHANGE_WINDOW_STYLE) {
+ // Ensure we don't return until the resize has actually happened,
+ // or 500ms have passed.
+
+ boolean bShouldWait = false;
+
+ if (data instanceof Integer) {
+ // Let's figure out if we're already laid out fullscreen or not.
+ Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+ DisplayMetrics realMetrics = new DisplayMetrics();
+ display.getRealMetrics(realMetrics);
+
+ boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) &&
+ (realMetrics.heightPixels == mSurface.getHeight()));
+
+ if ((Integer) data == 1) {
+ // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going
+ // to change size and should wait for surfaceChanged() before we return, so the size
+ // is right back in native code. If we're already laid out fullscreen, though, we're
+ // not going to change size even if we change decor modes, so we shouldn't wait for
+ // surfaceChanged() -- which may not even happen -- and should return immediately.
+ bShouldWait = !bFullscreenLayout;
+ } else {
+ // If we're laid out fullscreen (even if the status bar and nav bar are present),
+ // or are actively in fullscreen, we're going to change size and should wait for
+ // surfaceChanged before we return, so the size is right back in native code.
+ bShouldWait = bFullscreenLayout;
+ }
+ }
+
+ if (bShouldWait && (SDLActivity.getContext() != null)) {
+ // We'll wait for the surfaceChanged() method, which will notify us
+ // when called. That way, we know our current size is really the
+ // size we need, instead of grabbing a size that's still got
+ // the navigation and/or status bars before they're hidden.
+ //
+ // We'll wait for up to half a second, because some devices
+ // take a surprisingly long time for the surface resize, but
+ // then we'll just give up and return.
+ //
+ synchronized (SDLActivity.getContext()) {
+ try {
+ SDLActivity.getContext().wait(500);
+ } catch (InterruptedException ie) {
+ ie.printStackTrace();
+ }
+ }
+ }
+ }
+ }
+
+ return result;
+ }
+
+ // C functions we call
+ public static native String nativeGetVersion();
+ public static native int nativeSetupJNI();
+ public static native void nativeInitMainThread();
+ public static native void nativeCleanupMainThread();
+ public static native int nativeRunMain(String library, String function, Object arguments);
+ public static native void nativeLowMemory();
+ public static native void nativeSendQuit();
+ public static native void nativeQuit();
+ public static native void nativePause();
+ public static native void nativeResume();
+ public static native void nativeFocusChanged(boolean hasFocus);
+ public static native void onNativeDropFile(String filename);
+ public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float density, float rate);
+ public static native void onNativeResize();
+ public static native void onNativeKeyDown(int keycode);
+ public static native void onNativeKeyUp(int keycode);
+ public static native boolean onNativeSoftReturnKey();
+ public static native void onNativeKeyboardFocusLost();
+ public static native void onNativeMouse(int button, int action, float x, float y, boolean relative);
+ public static native void onNativeTouch(int touchDevId, int pointerFingerId,
+ int action, float x,
+ float y, float p);
+ public static native void onNativePen(int penId, int button, int action, float x, float y, float p);
+ public static native void onNativeAccel(float x, float y, float z);
+ public static native void onNativeClipboardChanged();
+ public static native void onNativeSurfaceCreated();
+ public static native void onNativeSurfaceChanged();
+ public static native void onNativeSurfaceDestroyed();
+ public static native String nativeGetHint(String name);
+ public static native boolean nativeGetHintBoolean(String name, boolean default_value);
+ public static native void nativeSetenv(String name, String value);
+ public static native void nativeSetNaturalOrientation(int orientation);
+ public static native void onNativeRotationChanged(int rotation);
+ public static native void onNativeInsetsChanged(int left, int right, int top, int bottom);
+ public static native void nativeAddTouch(int touchId, String name);
+ public static native void nativePermissionResult(int requestCode, boolean result);
+ public static native void onNativeLocaleChanged();
+ public static native void onNativeDarkModeChanged(boolean enabled);
+ public static native boolean nativeAllowRecreateActivity();
+ public static native int nativeCheckSDLThreadCounter();
+ public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter);
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setActivityTitle(String title) {
+ // Called from SDLMain() thread and can't directly affect the view
+ return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void setWindowStyle(boolean fullscreen) {
+ // Called from SDLMain() thread and can't directly affect the view
+ mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ * This is a static method for JNI convenience, it calls a non-static method
+ * so that is can be overridden
+ */
+ public static void setOrientation(int w, int h, boolean resizable, String hint)
+ {
+ if (mSingleton != null) {
+ mSingleton.setOrientationBis(w, h, resizable, hint);
+ }
+ }
+
+ /**
+ * This can be overridden
+ */
+ public void setOrientationBis(int w, int h, boolean resizable, String hint)
+ {
+ int orientation_landscape = -1;
+ int orientation_portrait = -1;
+
+ /* If set, hint "explicitly controls which UI orientations are allowed". */
+ if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE;
+ } else if (hint.contains("LandscapeLeft")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+ } else if (hint.contains("LandscapeRight")) {
+ orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+ }
+
+ /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */
+ boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait");
+
+ if (contains_Portrait && hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT;
+ } else if (contains_Portrait) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+ } else if (hint.contains("PortraitUpsideDown")) {
+ orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+ }
+
+ boolean is_landscape_allowed = (orientation_landscape != -1);
+ boolean is_portrait_allowed = (orientation_portrait != -1);
+ int req; /* Requested orientation */
+
+ /* No valid hint, nothing is explicitly allowed */
+ if (!is_portrait_allowed && !is_landscape_allowed) {
+ if (resizable) {
+ /* All orientations are allowed, respecting user orientation lock setting */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
+ } else {
+ /* Fixed window and nothing specified. Get orientation from w/h of created window */
+ req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT);
+ }
+ } else {
+ /* At least one orientation is allowed */
+ if (resizable) {
+ if (is_portrait_allowed && is_landscape_allowed) {
+ /* hint allows both landscape and portrait, promote to full user */
+ req = ActivityInfo.SCREEN_ORIENTATION_FULL_USER;
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ } else {
+ /* Fixed window and both orientations are allowed. Choose one. */
+ if (is_portrait_allowed && is_landscape_allowed) {
+ req = (w > h ? orientation_landscape : orientation_portrait);
+ } else {
+ /* Use the only one allowed "orientation" */
+ req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+ }
+ }
+ }
+
+ Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint);
+ mSingleton.setRequestedOrientation(req);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void minimizeWindow() {
+
+ if (mSingleton == null) {
+ return;
+ }
+
+ Intent startMain = new Intent(Intent.ACTION_MAIN);
+ startMain.addCategory(Intent.CATEGORY_HOME);
+ startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ mSingleton.startActivity(startMain);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean shouldMinimizeOnFocusLoss() {
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isScreenKeyboardShown()
+ {
+ if (mTextEdit == null) {
+ return false;
+ }
+
+ if (!mScreenKeyboardShown) {
+ return false;
+ }
+
+ InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ return imm.isAcceptingText();
+
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean supportsRelativeMouse()
+ {
+ // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under
+ // Android 7 APIs, and simply returns no data under Android 8 APIs.
+ //
+ // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and
+ // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result,
+ // we should stick to relative mode.
+ //
+ if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().supportsRelativeMouse();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setRelativeMouseEnabled(boolean enabled)
+ {
+ if (enabled && !supportsRelativeMouse()) {
+ return false;
+ }
+
+ return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean sendMessage(int command, int param) {
+ if (mSingleton == null) {
+ return false;
+ }
+ return mSingleton.sendCommand(command, param);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Context getContext() {
+ return SDL.getContext();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isAndroidTV() {
+ UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE);
+ if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV")) {
+ return true;
+ }
+ return false;
+ }
+
+ public static boolean isVRHeadset() {
+ if (Build.MANUFACTURER.equals("Oculus") && Build.MODEL.startsWith("Quest")) {
+ return true;
+ }
+ if (Build.MANUFACTURER.equals("Pico")) {
+ return true;
+ }
+ return false;
+ }
+
+ public static double getDiagonal()
+ {
+ DisplayMetrics metrics = new DisplayMetrics();
+ Activity activity = (Activity)getContext();
+ if (activity == null) {
+ return 0.0;
+ }
+ activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
+
+ double dWidthInches = metrics.widthPixels / (double)metrics.xdpi;
+ double dHeightInches = metrics.heightPixels / (double)metrics.ydpi;
+
+ return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches));
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isTablet() {
+ // If our diagonal size is seven inches or greater, we consider ourselves a tablet.
+ return (getDiagonal() >= 7.0);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isChromebook() {
+ if (getContext() == null) {
+ return false;
+ }
+ return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean isDeXMode() {
+ if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) {
+ return false;
+ }
+ try {
+ final Configuration config = getContext().getResources().getConfiguration();
+ final Class> configClass = config.getClass();
+ return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
+ == configClass.getField("semDesktopModeEnabled").getInt(config);
+ } catch(Exception ignored) {
+ return false;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean getManifestEnvironmentVariables() {
+ try {
+ if (getContext() == null) {
+ return false;
+ }
+
+ ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA);
+ Bundle bundle = applicationInfo.metaData;
+ if (bundle == null) {
+ return false;
+ }
+ String prefix = "SDL_ENV.";
+ final int trimLength = prefix.length();
+ for (String key : bundle.keySet()) {
+ if (key.startsWith(prefix)) {
+ String name = key.substring(trimLength);
+ String value = bundle.get(key).toString();
+ nativeSetenv(name, value);
+ }
+ }
+ /* environment variables set! */
+ return true;
+ } catch (Exception e) {
+ Log.v(TAG, "exception " + e.toString());
+ }
+ return false;
+ }
+
+ // This method is called by SDLControllerManager's API 26 Generic Motion Handler.
+ public static View getContentView() {
+ return mLayout;
+ }
+
+ static class ShowTextInputTask implements Runnable {
+ /*
+ * This is used to regulate the pan&scan method to have some offset from
+ * the bottom edge of the input region and the top edge of an input
+ * method (soft keyboard)
+ */
+ static final int HEIGHT_PADDING = 15;
+
+ public int input_type;
+ public int x, y, w, h;
+
+ public ShowTextInputTask(int input_type, int x, int y, int w, int h) {
+ this.input_type = input_type;
+ this.x = x;
+ this.y = y;
+ this.w = w;
+ this.h = h;
+
+ /* Minimum size of 1 pixel, so it takes focus. */
+ if (this.w <= 0) {
+ this.w = 1;
+ }
+ if (this.h + HEIGHT_PADDING <= 0) {
+ this.h = 1 - HEIGHT_PADDING;
+ }
+ }
+
+ @Override
+ public void run() {
+ RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING);
+ params.leftMargin = x;
+ params.topMargin = y;
+
+ if (mTextEdit == null) {
+ mTextEdit = new SDLDummyEdit(SDL.getContext());
+
+ mLayout.addView(mTextEdit, params);
+ } else {
+ mTextEdit.setLayoutParams(params);
+ }
+ mTextEdit.setInputType(input_type);
+
+ mTextEdit.setVisibility(View.VISIBLE);
+ mTextEdit.requestFocus();
+
+ InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+ imm.showSoftInput(mTextEdit, 0);
+
+ mScreenKeyboardShown = true;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean showTextInput(int input_type, int x, int y, int w, int h) {
+ // Transfer the task to the main thread as a Runnable
+ return mSingleton.commandHandler.post(new ShowTextInputTask(input_type, x, y, w, h));
+ }
+
+ public static boolean isTextInputEvent(KeyEvent event) {
+
+ // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT
+ if (event.isCtrlPressed()) {
+ return false;
+ }
+
+ return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE;
+ }
+
+ public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) {
+ int deviceId = event.getDeviceId();
+ int source = event.getSource();
+
+ if (source == InputDevice.SOURCE_UNKNOWN) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ if (device != null) {
+ source = device.getSources();
+ }
+ }
+
+// if (event.getAction() == KeyEvent.ACTION_DOWN) {
+// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// } else if (event.getAction() == KeyEvent.ACTION_UP) {
+// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+// }
+
+ // Dispatch the different events depending on where they come from
+ // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD
+ // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD
+ //
+ // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and
+ // SOURCE_JOYSTICK, while its key events arrive from the keyboard source
+ // So, retrieve the device itself and check all of its sources
+ if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) {
+ // Note that we process events with specific key codes here
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ if (SDLControllerManager.onNativePadDown(deviceId, keyCode)) {
+ return true;
+ }
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ if (SDLControllerManager.onNativePadUp(deviceId, keyCode)) {
+ return true;
+ }
+ }
+ }
+
+ if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) {
+ if (SDLActivity.isVRHeadset()) {
+ // The Oculus Quest controller back button comes in as source mouse, so accept that
+ } else {
+ // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses
+ // they are ignored here because sending them as mouse input to SDL is messy
+ if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) {
+ switch (event.getAction()) {
+ case KeyEvent.ACTION_DOWN:
+ case KeyEvent.ACTION_UP:
+ // mark the event as handled or it will be handled by system
+ // handling KEYCODE_BACK by system will call onBackPressed()
+ return true;
+ }
+ }
+ }
+ }
+
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ onNativeKeyDown(keyCode);
+
+ if (isTextInputEvent(event)) {
+ if (ic != null) {
+ ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ } else {
+ SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1);
+ }
+ }
+ return true;
+ } else if (event.getAction() == KeyEvent.ACTION_UP) {
+ onNativeKeyUp(keyCode);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static Surface getNativeSurface() {
+ if (SDLActivity.mSurface == null) {
+ return null;
+ }
+ return SDLActivity.mSurface.getNativeSurface();
+ }
+
+ // Input
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void initTouch() {
+ int[] ids = InputDevice.getDeviceIds();
+
+ for (int id : ids) {
+ InputDevice device = InputDevice.getDevice(id);
+ /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */
+ if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN
+ || device.isVirtual())) {
+
+ nativeAddTouch(device.getId(), device.getName());
+ }
+ }
+ }
+
+ // Messagebox
+
+ /** Result of current messagebox. Also used for blocking the calling thread. */
+ protected final int[] messageboxSelection = new int[1];
+
+ /**
+ * This method is called by SDL using JNI.
+ * Shows the messagebox from UI thread and block calling thread.
+ * buttonFlags, buttonIds and buttonTexts must have same length.
+ * @param buttonFlags array containing flags for every button.
+ * @param buttonIds array containing id for every button.
+ * @param buttonTexts array containing text for every button.
+ * @param colors null for default or array of length 5 containing colors.
+ * @return button id or -1.
+ */
+ public int messageboxShowMessageBox(
+ final int flags,
+ final String title,
+ final String message,
+ final int[] buttonFlags,
+ final int[] buttonIds,
+ final String[] buttonTexts,
+ final int[] colors) {
+
+ messageboxSelection[0] = -1;
+
+ // sanity checks
+
+ if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) {
+ return -1; // implementation broken
+ }
+
+ // collect arguments for Dialog
+
+ final Bundle args = new Bundle();
+ args.putInt("flags", flags);
+ args.putString("title", title);
+ args.putString("message", message);
+ args.putIntArray("buttonFlags", buttonFlags);
+ args.putIntArray("buttonIds", buttonIds);
+ args.putStringArray("buttonTexts", buttonTexts);
+ args.putIntArray("colors", colors);
+
+ // trigger Dialog creation on UI thread
+
+ runOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ messageboxCreateAndShow(args);
+ }
+ });
+
+ // block the calling thread
+
+ synchronized (messageboxSelection) {
+ try {
+ messageboxSelection.wait();
+ } catch (InterruptedException ex) {
+ ex.printStackTrace();
+ return -1;
+ }
+ }
+
+ // return selected value
+
+ return messageboxSelection[0];
+ }
+
+ protected void messageboxCreateAndShow(Bundle args) {
+
+ // TODO set values from "flags" to messagebox dialog
+
+ // get colors
+
+ int[] colors = args.getIntArray("colors");
+ int backgroundColor;
+ int textColor;
+ int buttonBorderColor;
+ int buttonBackgroundColor;
+ int buttonSelectedColor;
+ if (colors != null) {
+ int i = -1;
+ backgroundColor = colors[++i];
+ textColor = colors[++i];
+ buttonBorderColor = colors[++i];
+ buttonBackgroundColor = colors[++i];
+ buttonSelectedColor = colors[++i];
+ } else {
+ backgroundColor = Color.TRANSPARENT;
+ textColor = Color.TRANSPARENT;
+ buttonBorderColor = Color.TRANSPARENT;
+ buttonBackgroundColor = Color.TRANSPARENT;
+ buttonSelectedColor = Color.TRANSPARENT;
+ }
+
+ // create dialog with title and a listener to wake up calling thread
+
+ final AlertDialog dialog = new AlertDialog.Builder(this).create();
+ dialog.setTitle(args.getString("title"));
+ dialog.setCancelable(false);
+ dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
+ @Override
+ public void onDismiss(DialogInterface unused) {
+ synchronized (messageboxSelection) {
+ messageboxSelection.notify();
+ }
+ }
+ });
+
+ // create text
+
+ TextView message = new TextView(this);
+ message.setGravity(Gravity.CENTER);
+ message.setText(args.getString("message"));
+ if (textColor != Color.TRANSPARENT) {
+ message.setTextColor(textColor);
+ }
+
+ // create buttons
+
+ int[] buttonFlags = args.getIntArray("buttonFlags");
+ int[] buttonIds = args.getIntArray("buttonIds");
+ String[] buttonTexts = args.getStringArray("buttonTexts");
+
+ final SparseArray mapping = new SparseArray();
+
+ LinearLayout buttons = new LinearLayout(this);
+ buttons.setOrientation(LinearLayout.HORIZONTAL);
+ buttons.setGravity(Gravity.CENTER);
+ for (int i = 0; i < buttonTexts.length; ++i) {
+ Button button = new Button(this);
+ final int id = buttonIds[i];
+ button.setOnClickListener(new View.OnClickListener() {
+ @Override
+ public void onClick(View v) {
+ messageboxSelection[0] = id;
+ dialog.dismiss();
+ }
+ });
+ if (buttonFlags[i] != 0) {
+ // see SDL_messagebox.h
+ if ((buttonFlags[i] & 0x00000001) != 0) {
+ mapping.put(KeyEvent.KEYCODE_ENTER, button);
+ }
+ if ((buttonFlags[i] & 0x00000002) != 0) {
+ mapping.put(KeyEvent.KEYCODE_ESCAPE, button); /* API 11 */
+ }
+ }
+ button.setText(buttonTexts[i]);
+ if (textColor != Color.TRANSPARENT) {
+ button.setTextColor(textColor);
+ }
+ if (buttonBorderColor != Color.TRANSPARENT) {
+ // TODO set color for border of messagebox button
+ }
+ if (buttonBackgroundColor != Color.TRANSPARENT) {
+ Drawable drawable = button.getBackground();
+ if (drawable == null) {
+ // setting the color this way removes the style
+ button.setBackgroundColor(buttonBackgroundColor);
+ } else {
+ // setting the color this way keeps the style (gradient, padding, etc.)
+ drawable.setColorFilter(buttonBackgroundColor, PorterDuff.Mode.MULTIPLY);
+ }
+ }
+ if (buttonSelectedColor != Color.TRANSPARENT) {
+ // TODO set color for selected messagebox button
+ }
+ buttons.addView(button);
+ }
+
+ // create content
+
+ LinearLayout content = new LinearLayout(this);
+ content.setOrientation(LinearLayout.VERTICAL);
+ content.addView(message);
+ content.addView(buttons);
+ if (backgroundColor != Color.TRANSPARENT) {
+ content.setBackgroundColor(backgroundColor);
+ }
+
+ // add content to dialog and return
+
+ dialog.setView(content);
+ dialog.setOnKeyListener(new Dialog.OnKeyListener() {
+ @Override
+ public boolean onKey(DialogInterface d, int keyCode, KeyEvent event) {
+ Button button = mapping.get(keyCode);
+ if (button != null) {
+ if (event.getAction() == KeyEvent.ACTION_UP) {
+ button.performClick();
+ }
+ return true; // also for ignored actions
+ }
+ return false;
+ }
+ });
+
+ dialog.show();
+ }
+
+ private final Runnable rehideSystemUi = new Runnable() {
+ @Override
+ public void run() {
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+ View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+ View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+ View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+
+ SDLActivity.this.getWindow().getDecorView().setSystemUiVisibility(flags);
+ }
+ }
+ };
+
+ public void onSystemUiVisibilityChange(int visibility) {
+ if (SDLActivity.mFullscreenModeActive && ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 || (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0)) {
+
+ Handler handler = getWindow().getDecorView().getHandler();
+ if (handler != null) {
+ handler.removeCallbacks(rehideSystemUi); // Prevent a hide loop.
+ handler.postDelayed(rehideSystemUi, 2000);
+ }
+
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean clipboardHasText() {
+ return mClipboardHandler.clipboardHasText();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static String clipboardGetText() {
+ return mClipboardHandler.clipboardGetText();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void clipboardSetText(String string) {
+ mClipboardHandler.clipboardSetText(string);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int createCustomCursor(int[] colors, int width, int height, int hotSpotX, int hotSpotY) {
+ Bitmap bitmap = Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
+ ++mLastCursorID;
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mCursors.put(mLastCursorID, PointerIcon.create(bitmap, hotSpotX, hotSpotY));
+ } catch (Exception e) {
+ return 0;
+ }
+ } else {
+ return 0;
+ }
+ return mLastCursorID;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void destroyCustomCursor(int cursorID) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mCursors.remove(cursorID);
+ } catch (Exception e) {
+ }
+ }
+ return;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setCustomCursor(int cursorID) {
+
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mSurface.setPointerIcon(mCursors.get(cursorID));
+ } catch (Exception e) {
+ return false;
+ }
+ } else {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean setSystemCursor(int cursorID) {
+ int cursor_type = 0; //PointerIcon.TYPE_NULL;
+ switch (cursorID) {
+ case SDL_SYSTEM_CURSOR_ARROW:
+ cursor_type = 1000; //PointerIcon.TYPE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_IBEAM:
+ cursor_type = 1008; //PointerIcon.TYPE_TEXT;
+ break;
+ case SDL_SYSTEM_CURSOR_WAIT:
+ cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+ break;
+ case SDL_SYSTEM_CURSOR_CROSSHAIR:
+ cursor_type = 1007; //PointerIcon.TYPE_CROSSHAIR;
+ break;
+ case SDL_SYSTEM_CURSOR_WAITARROW:
+ cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENWSE:
+ cursor_type = 1017; //PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENESW:
+ cursor_type = 1016; //PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZEWE:
+ cursor_type = 1014; //PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZENS:
+ cursor_type = 1015; //PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_SIZEALL:
+ cursor_type = 1020; //PointerIcon.TYPE_GRAB;
+ break;
+ case SDL_SYSTEM_CURSOR_NO:
+ cursor_type = 1012; //PointerIcon.TYPE_NO_DROP;
+ break;
+ case SDL_SYSTEM_CURSOR_HAND:
+ cursor_type = 1002; //PointerIcon.TYPE_HAND;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_TOPLEFT:
+ cursor_type = 1017; //PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_TOP:
+ cursor_type = 1015; //PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_TOPRIGHT:
+ cursor_type = 1016; //PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_RIGHT:
+ cursor_type = 1014; //PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_BOTTOMRIGHT:
+ cursor_type = 1017; //PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_BOTTOM:
+ cursor_type = 1015; //PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_BOTTOMLEFT:
+ cursor_type = 1016; //PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW;
+ break;
+ case SDL_SYSTEM_CURSOR_WINDOW_LEFT:
+ cursor_type = 1014; //PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
+ break;
+ }
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ try {
+ mSurface.setPointerIcon(PointerIcon.getSystemIcon(SDL.getContext(), cursor_type));
+ } catch (Exception e) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void requestPermission(String permission, int requestCode) {
+ if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
+ nativePermissionResult(requestCode, true);
+ return;
+ }
+
+ Activity activity = (Activity)getContext();
+ if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
+ activity.requestPermissions(new String[]{permission}, requestCode);
+ } else {
+ nativePermissionResult(requestCode, true);
+ }
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+ boolean result = (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED);
+ nativePermissionResult(requestCode, result);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean openURL(String url)
+ {
+ try {
+ Intent i = new Intent(Intent.ACTION_VIEW);
+ i.setData(Uri.parse(url));
+
+ int flags = Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+ if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) {
+ flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+ } else {
+ flags |= Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET;
+ }
+ i.addFlags(flags);
+
+ mSingleton.startActivity(i);
+ } catch (Exception ex) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean showToast(String message, int duration, int gravity, int xOffset, int yOffset)
+ {
+ if(null == mSingleton) {
+ return false;
+ }
+
+ try
+ {
+ class OneShotTask implements Runnable {
+ private final String mMessage;
+ private final int mDuration;
+ private final int mGravity;
+ private final int mXOffset;
+ private final int mYOffset;
+
+ OneShotTask(String message, int duration, int gravity, int xOffset, int yOffset) {
+ mMessage = message;
+ mDuration = duration;
+ mGravity = gravity;
+ mXOffset = xOffset;
+ mYOffset = yOffset;
+ }
+
+ public void run() {
+ try
+ {
+ Toast toast = Toast.makeText(mSingleton, mMessage, mDuration);
+ if (mGravity >= 0) {
+ toast.setGravity(mGravity, mXOffset, mYOffset);
+ }
+ toast.show();
+ } catch(Exception ex) {
+ Log.e(TAG, ex.getMessage());
+ }
+ }
+ }
+ mSingleton.runOnUiThread(new OneShotTask(message, duration, gravity, xOffset, yOffset));
+ } catch(Exception ex) {
+ return false;
+ }
+ return true;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static int openFileDescriptor(String uri, String mode) throws Exception {
+ if (mSingleton == null) {
+ return -1;
+ }
+
+ try {
+ ParcelFileDescriptor pfd = mSingleton.getContentResolver().openFileDescriptor(Uri.parse(uri), mode);
+ return pfd != null ? pfd.detachFd() : -1;
+ } catch (FileNotFoundException e) {
+ e.printStackTrace();
+ return -1;
+ }
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static boolean showFileDialog(String[] filters, boolean allowMultiple, boolean forWrite, int requestCode) {
+ if (mSingleton == null) {
+ return false;
+ }
+
+ if (forWrite) {
+ allowMultiple = false;
+ }
+
+ /* Convert string list of extensions to their respective MIME types */
+ ArrayList mimes = new ArrayList<>();
+ MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+ if (filters != null) {
+ for (String pattern : filters) {
+ String[] extensions = pattern.split(";");
+
+ if (extensions.length == 1 && extensions[0].equals("*")) {
+ /* Handle "*" special case */
+ mimes.add("*/*");
+ } else {
+ for (String ext : extensions) {
+ String mime = mimeTypeMap.getMimeTypeFromExtension(ext);
+ if (mime != null) {
+ mimes.add(mime);
+ }
+ }
+ }
+ }
+ }
+
+ /* Display the file dialog */
+ Intent intent = new Intent(forWrite ? Intent.ACTION_CREATE_DOCUMENT : Intent.ACTION_OPEN_DOCUMENT);
+ intent.addCategory(Intent.CATEGORY_OPENABLE);
+ intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
+ switch (mimes.size()) {
+ case 0:
+ intent.setType("*/*");
+ break;
+ case 1:
+ intent.setType(mimes.get(0));
+ break;
+ default:
+ intent.setType("*/*");
+ intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes.toArray(new String[]{}));
+ }
+
+ try {
+ mSingleton.startActivityForResult(intent, requestCode);
+ } catch (ActivityNotFoundException e) {
+ Log.e(TAG, "Unable to open file dialog.", e);
+ return false;
+ }
+
+ /* Save current dialog state */
+ mFileDialogState = new SDLFileDialogState();
+ mFileDialogState.requestCode = requestCode;
+ mFileDialogState.multipleChoice = allowMultiple;
+ return true;
+ }
+
+ /* Internal class used to track active open file dialog */
+ static class SDLFileDialogState {
+ int requestCode;
+ boolean multipleChoice;
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static String getPreferredLocales() {
+ String result = "";
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7 (N) */) {
+ LocaleList locales = LocaleList.getAdjustedDefault();
+ for (int i = 0; i < locales.size(); i++) {
+ if (i != 0) result += ",";
+ result += formatLocale(locales.get(i));
+ }
+ } else if (mCurrentLocale != null) {
+ result = formatLocale(mCurrentLocale);
+ }
+ return result;
+ }
+
+ public static String formatLocale(Locale locale) {
+ String result = "";
+ String lang = "";
+ if (locale.getLanguage() == "in") {
+ // Indonesian is "id" according to ISO 639.2, but on Android is "in" because of Java backwards compatibility
+ lang = "id";
+ } else if (locale.getLanguage() == "") {
+ // Make sure language is never empty
+ lang = "und";
+ } else {
+ lang = locale.getLanguage();
+ }
+
+ if (locale.getCountry() == "") {
+ result = lang;
+ } else {
+ result = lang + "_" + locale.getCountry();
+ }
+ return result;
+ }
+}
+
+/**
+ Simple runnable to start the SDL application
+*/
+class SDLMain implements Runnable {
+ @Override
+ public void run() {
+ // Runs SDLActivity.main()
+
+ try {
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DISPLAY);
+ } catch (Exception e) {
+ Log.v("SDL", "modify thread properties failed " + e.toString());
+ }
+
+ SDLActivity.nativeInitMainThread();
+ SDLActivity.mSingleton.main();
+ SDLActivity.nativeCleanupMainThread();
+
+ if (SDLActivity.mSingleton != null && !SDLActivity.mSingleton.isFinishing()) {
+ // Let's finish the Activity
+ SDLActivity.mSDLThread = null;
+ SDLActivity.mSDLMainFinished = true;
+ SDLActivity.mSingleton.finish();
+ } // else: Activity is already being destroyed
+
+ }
+}
+
+class SDLClipboardHandler implements
+ ClipboardManager.OnPrimaryClipChangedListener {
+
+ protected ClipboardManager mClipMgr;
+
+ SDLClipboardHandler() {
+ mClipMgr = (ClipboardManager) SDL.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+ mClipMgr.addPrimaryClipChangedListener(this);
+ }
+
+ public boolean clipboardHasText() {
+ return mClipMgr.hasPrimaryClip();
+ }
+
+ public String clipboardGetText() {
+ ClipData clip = mClipMgr.getPrimaryClip();
+ if (clip != null) {
+ ClipData.Item item = clip.getItemAt(0);
+ if (item != null) {
+ CharSequence text = item.getText();
+ if (text != null) {
+ return text.toString();
+ }
+ }
+ }
+ return null;
+ }
+
+ public void clipboardSetText(String string) {
+ mClipMgr.removePrimaryClipChangedListener(this);
+ ClipData clip = ClipData.newPlainText(null, string);
+ mClipMgr.setPrimaryClip(clip);
+ mClipMgr.addPrimaryClipChangedListener(this);
+ }
+
+ @Override
+ public void onPrimaryClipChanged() {
+ SDLActivity.onNativeClipboardChanged();
+ }
+}
+
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLAudioManager.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLAudioManager.java
new file mode 100644
index 0000000000..2780126677
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLAudioManager.java
@@ -0,0 +1,128 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.content.Context;
+import android.media.AudioDeviceCallback;
+import android.media.AudioDeviceInfo;
+import android.media.AudioManager;
+import android.os.Build;
+import android.util.Log;
+
+public class SDLAudioManager {
+ protected static final String TAG = "SDLAudio";
+
+ protected static Context mContext;
+
+ private static AudioDeviceCallback mAudioDeviceCallback;
+
+ public static void initialize() {
+ mAudioDeviceCallback = null;
+
+ if(Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */)
+ {
+ mAudioDeviceCallback = new AudioDeviceCallback() {
+ @Override
+ public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
+ for (AudioDeviceInfo deviceInfo : addedDevices) {
+ addAudioDevice(deviceInfo.isSink(), deviceInfo.getProductName().toString(), deviceInfo.getId());
+ }
+ }
+
+ @Override
+ public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
+ for (AudioDeviceInfo deviceInfo : removedDevices) {
+ removeAudioDevice(deviceInfo.isSink(), deviceInfo.getId());
+ }
+ }
+ };
+ }
+ }
+
+ public static void setContext(Context context) {
+ mContext = context;
+ }
+
+ public static void release(Context context) {
+ // no-op atm
+ }
+
+ // Audio
+
+ private static AudioDeviceInfo getInputAudioDeviceInfo(int deviceId) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ for (AudioDeviceInfo deviceInfo : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
+ if (deviceInfo.getId() == deviceId) {
+ return deviceInfo;
+ }
+ }
+ }
+ return null;
+ }
+
+ private static AudioDeviceInfo getPlaybackAudioDeviceInfo(int deviceId) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ for (AudioDeviceInfo deviceInfo : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ if (deviceInfo.getId() == deviceId) {
+ return deviceInfo;
+ }
+ }
+ }
+ return null;
+ }
+
+ public static void registerAudioDeviceCallback() {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ // get an initial list now, before hotplug callbacks fire.
+ for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)) {
+ if (dev.getType() == AudioDeviceInfo.TYPE_TELEPHONY) {
+ continue; // Device cannot be opened
+ }
+ addAudioDevice(dev.isSink(), dev.getProductName().toString(), dev.getId());
+ }
+ for (AudioDeviceInfo dev : audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)) {
+ addAudioDevice(dev.isSink(), dev.getProductName().toString(), dev.getId());
+ }
+ audioManager.registerAudioDeviceCallback(mAudioDeviceCallback, null);
+ }
+ }
+
+ public static void unregisterAudioDeviceCallback() {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
+ audioManager.unregisterAudioDeviceCallback(mAudioDeviceCallback);
+ }
+ }
+
+ /** This method is called by SDL using JNI. */
+ public static void audioSetThreadPriority(boolean recording, int device_id) {
+ try {
+
+ /* Set thread name */
+ if (recording) {
+ Thread.currentThread().setName("SDLAudioC" + device_id);
+ } else {
+ Thread.currentThread().setName("SDLAudioP" + device_id);
+ }
+
+ /* Set thread priority */
+ android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
+
+ } catch (Exception e) {
+ Log.v(TAG, "modify thread properties failed " + e.toString());
+ }
+ }
+
+ public static native int nativeSetupJNI();
+
+ public static native void removeAudioDevice(boolean recording, int deviceId);
+
+ public static native void addAudioDevice(boolean recording, String name, int deviceId);
+
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLControllerManager.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLControllerManager.java
new file mode 100644
index 0000000000..48be7927bf
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLControllerManager.java
@@ -0,0 +1,876 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * This file has been modified for this project's needs.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.os.VibratorManager;
+import android.system.Os;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+
+import net.kdt.pojavlaunch.MinecraftGLSurface;
+import net.kdt.pojavlaunch.customcontrols.gamepad.direct.DirectGamepadEnableHandler;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
+
+
+public class SDLControllerManager
+{
+
+ public static native int nativeSetupJNI();
+
+ public static native void nativeAddJoystick(int device_id, String name, String desc,
+ int vendor_id, int product_id,
+ int button_mask,
+ int naxes, int axis_mask, int nhats, boolean can_rumble);
+ public static native void nativeRemoveJoystick(int device_id);
+ public static native void nativeAddHaptic(int device_id, String name);
+ public static native void nativeRemoveHaptic(int device_id);
+ public static native boolean onNativePadDown(int device_id, int keycode);
+ public static native boolean onNativePadUp(int device_id, int keycode);
+ public static native void onNativeJoy(int device_id, int axis,
+ float value);
+ public static native void onNativeHat(int device_id, int hat_id,
+ int x, int y);
+
+ protected static SDLJoystickHandler mJoystickHandler;
+ protected static SDLHapticHandler mHapticHandler;
+
+ private static final String TAG = "SDLControllerManager";
+
+ public static void initialize() {
+ if (mJoystickHandler == null) {
+ if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
+ mJoystickHandler = new SDLJoystickHandler_API19();
+ } else {
+ mJoystickHandler = new SDLJoystickHandler_API16();
+ }
+ }
+
+ if (mHapticHandler == null) {
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
+ mHapticHandler = new SDLHapticHandler_API31();
+ } else if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
+ mHapticHandler = new SDLHapticHandler_API26();
+ } else {
+ mHapticHandler = new SDLHapticHandler();
+ }
+ }
+ }
+
+ // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
+ public static boolean handleJoystickMotionEvent(MotionEvent event) {
+ return mJoystickHandler.handleMotionEvent(event);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void pollInputDevices() {
+ mJoystickHandler.pollInputDevices();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void pollHapticDevices() {
+ mHapticHandler.pollHapticDevices();
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void hapticRun(int device_id, float intensity, int length) {
+ mHapticHandler.run(device_id, intensity, length);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void hapticRumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) {
+ mHapticHandler.rumble(device_id, low_frequency_intensity, high_frequency_intensity, length);
+ }
+
+ /**
+ * This method is called by SDL using JNI.
+ */
+ public static void hapticStop(int device_id)
+ {
+ mHapticHandler.stop(device_id);
+ }
+
+ // Check if a given device is considered a possible SDL joystick
+ public static boolean isDeviceSDLJoystick(int deviceId) {
+ InputDevice device = InputDevice.getDevice(deviceId);
+ // We cannot use InputDevice.isVirtual before API 16, so let's accept
+ // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
+ if ((device == null) || (deviceId < 0)) {
+ return false;
+ }
+ int sources = device.getSources();
+
+ /* This is called for every button press, so let's not spam the logs */
+ /*
+ if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+ Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
+ }
+ if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
+ Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
+ }
+ if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+ Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
+ }
+ */
+
+ return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
+ ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
+ ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
+ );
+ }
+
+ public static void setDirectGamepadEnableHandler(DirectGamepadEnableHandler h) {
+ SDLJoystickHandler_API16.sDirectGamepadEnableHandler = h;
+ }
+}
+
+class SDLJoystickHandler {
+
+ /**
+ * Handles given MotionEvent.
+ * @param event the event to be handled.
+ * @return if given event was processed.
+ */
+ public boolean handleMotionEvent(MotionEvent event) {
+ return false;
+ }
+
+ /**
+ * Handles adding and removing of input devices.
+ */
+ public void pollInputDevices() {
+ }
+}
+
+/* Actual joystick functionality available for API >= 12 devices */
+class SDLJoystickHandler_API16 extends SDLJoystickHandler {
+
+ static class SDLJoystick {
+ public int device_id;
+ public String name;
+ public String desc;
+ public ArrayList axes;
+ public ArrayList hats;
+ }
+ static class RangeComparator implements Comparator {
+ @Override
+ public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
+ // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
+ int arg0Axis = arg0.getAxis();
+ int arg1Axis = arg1.getAxis();
+ if (arg0Axis == MotionEvent.AXIS_GAS) {
+ arg0Axis = MotionEvent.AXIS_BRAKE;
+ } else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
+ arg0Axis = MotionEvent.AXIS_GAS;
+ }
+ if (arg1Axis == MotionEvent.AXIS_GAS) {
+ arg1Axis = MotionEvent.AXIS_BRAKE;
+ } else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
+ arg1Axis = MotionEvent.AXIS_GAS;
+ }
+
+ // Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ.
+ // This is because the usual pairing are:
+ // - AXIS_X + AXIS_Y (left stick).
+ // - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers).
+ // - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers).
+ // This sorts the axes in the above order, which tends to be correct
+ // for Xbox-ish game pads that have the right stick on RX/RY and the
+ // triggers on Z/RZ.
+ //
+ // Gamepads that don't have AXIS_Z/AXIS_RZ but use
+ // AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this.
+ //
+ // References:
+ // - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
+ // - https://www.kernel.org/doc/html/latest/input/gamepad.html
+ if (arg0Axis == MotionEvent.AXIS_Z) {
+ arg0Axis = MotionEvent.AXIS_RZ - 1;
+ } else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) {
+ --arg0Axis;
+ }
+ if (arg1Axis == MotionEvent.AXIS_Z) {
+ arg1Axis = MotionEvent.AXIS_RZ - 1;
+ } else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) {
+ --arg1Axis;
+ }
+
+ return arg0Axis - arg1Axis;
+ }
+ }
+
+ private final ArrayList mJoysticks;
+
+ public SDLJoystickHandler_API16() {
+
+ mJoysticks = new ArrayList();
+ }
+
+ protected static DirectGamepadEnableHandler sDirectGamepadEnableHandler;
+ private static boolean firstPollDone = false;
+ @Override
+ public void pollInputDevices() {
+ if (!firstPollDone) {
+ MinecraftGLSurface.sdlEnabled = true;
+ if (sDirectGamepadEnableHandler != null){
+ sDirectGamepadEnableHandler.onDirectGamepadEnabled();
+ }
+ Log.i("SDL", "SDL detected! Enabling..");
+ sDirectGamepadEnableHandler = null;
+ firstPollDone = true;
+ }
+
+ int[] deviceIds = InputDevice.getDeviceIds();
+
+ for (int device_id : deviceIds) {
+ if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
+ SDLJoystick joystick = getJoystick(device_id);
+ if (joystick == null) {
+ InputDevice joystickDevice = InputDevice.getDevice(device_id);
+ joystick = new SDLJoystick();
+ joystick.device_id = device_id;
+ joystick.name = joystickDevice.getName();
+ joystick.desc = getJoystickDescriptor(joystickDevice);
+ joystick.axes = new ArrayList();
+ joystick.hats = new ArrayList();
+
+ List ranges = joystickDevice.getMotionRanges();
+ Collections.sort(ranges, new RangeComparator());
+ for (InputDevice.MotionRange range : ranges) {
+ if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+ if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
+ joystick.hats.add(range);
+ } else {
+ joystick.axes.add(range);
+ }
+ }
+ }
+
+ boolean can_rumble = false;
+ if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) {
+ VibratorManager manager = joystickDevice.getVibratorManager();
+ int[] vibrators = manager.getVibratorIds();
+ if (vibrators.length > 0) {
+ can_rumble = true;
+ }
+ }
+
+ mJoysticks.add(joystick);
+ SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
+ getVendorId(joystickDevice), getProductId(joystickDevice),
+ getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, can_rumble);
+ }
+ }
+ }
+
+ /* Check removed devices */
+ ArrayList removedDevices = null;
+ for (SDLJoystick joystick : mJoysticks) {
+ int device_id = joystick.device_id;
+ int i;
+ for (i = 0; i < deviceIds.length; i++) {
+ if (device_id == deviceIds[i]) break;
+ }
+ if (i == deviceIds.length) {
+ if (removedDevices == null) {
+ removedDevices = new ArrayList();
+ }
+ removedDevices.add(device_id);
+ }
+ }
+
+ if (removedDevices != null) {
+ for (int device_id : removedDevices) {
+ SDLControllerManager.nativeRemoveJoystick(device_id);
+ for (int i = 0; i < mJoysticks.size(); i++) {
+ if (mJoysticks.get(i).device_id == device_id) {
+ mJoysticks.remove(i);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ protected SDLJoystick getJoystick(int device_id) {
+ for (SDLJoystick joystick : mJoysticks) {
+ if (joystick.device_id == device_id) {
+ return joystick;
+ }
+ }
+ return null;
+ }
+
+ @Override
+ public boolean handleMotionEvent(MotionEvent event) {
+ int actionPointerIndex = event.getActionIndex();
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_MOVE) {
+ SDLJoystick joystick = getJoystick(event.getDeviceId());
+ if (joystick != null) {
+ for (int i = 0; i < joystick.axes.size(); i++) {
+ InputDevice.MotionRange range = joystick.axes.get(i);
+ /* Normalize the value to -1...1 */
+ float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
+ SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
+ }
+ for (int i = 0; i < joystick.hats.size() / 2; i++) {
+ int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
+ int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
+ SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
+ }
+ }
+ }
+ return true;
+ }
+
+ public String getJoystickDescriptor(InputDevice joystickDevice) {
+ String desc = joystickDevice.getDescriptor();
+
+ if (desc != null && !desc.isEmpty()) {
+ return desc;
+ }
+
+ return joystickDevice.getName();
+ }
+ public int getProductId(InputDevice joystickDevice) {
+ return 0;
+ }
+ public int getVendorId(InputDevice joystickDevice) {
+ return 0;
+ }
+ public int getAxisMask(List ranges) {
+ return -1;
+ }
+ public int getButtonMask(InputDevice joystickDevice) {
+ return -1;
+ }
+}
+
+class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
+
+ @Override
+ public int getProductId(InputDevice joystickDevice) {
+ return joystickDevice.getProductId();
+ }
+
+ @Override
+ public int getVendorId(InputDevice joystickDevice) {
+ return joystickDevice.getVendorId();
+ }
+
+ @Override
+ public int getAxisMask(List ranges) {
+ // For compatibility, keep computing the axis mask like before,
+ // only really distinguishing 2, 4 and 6 axes.
+ int axis_mask = 0;
+ if (ranges.size() >= 2) {
+ // ((1 << SDL_GAMEPAD_AXIS_LEFTX) | (1 << SDL_GAMEPAD_AXIS_LEFTY))
+ axis_mask |= 0x0003;
+ }
+ if (ranges.size() >= 4) {
+ // ((1 << SDL_GAMEPAD_AXIS_RIGHTX) | (1 << SDL_GAMEPAD_AXIS_RIGHTY))
+ axis_mask |= 0x000c;
+ }
+ if (ranges.size() >= 6) {
+ // ((1 << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1 << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER))
+ axis_mask |= 0x0030;
+ }
+ // Also add an indicator bit for whether the sorting order has changed.
+ // This serves to disable outdated gamecontrollerdb.txt mappings.
+ boolean have_z = false;
+ boolean have_past_z_before_rz = false;
+ for (InputDevice.MotionRange range : ranges) {
+ int axis = range.getAxis();
+ if (axis == MotionEvent.AXIS_Z) {
+ have_z = true;
+ } else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) {
+ have_past_z_before_rz = true;
+ }
+ }
+ if (have_z && have_past_z_before_rz) {
+ // If both these exist, the compare() function changed sorting order.
+ // Set a bit to indicate this fact.
+ axis_mask |= 0x8000;
+ }
+ return axis_mask;
+ }
+
+ @Override
+ public int getButtonMask(InputDevice joystickDevice) {
+ int button_mask = 0;
+ int[] keys = new int[] {
+ KeyEvent.KEYCODE_BUTTON_A,
+ KeyEvent.KEYCODE_BUTTON_B,
+ KeyEvent.KEYCODE_BUTTON_X,
+ KeyEvent.KEYCODE_BUTTON_Y,
+ KeyEvent.KEYCODE_BACK,
+ KeyEvent.KEYCODE_MENU,
+ KeyEvent.KEYCODE_BUTTON_MODE,
+ KeyEvent.KEYCODE_BUTTON_START,
+ KeyEvent.KEYCODE_BUTTON_THUMBL,
+ KeyEvent.KEYCODE_BUTTON_THUMBR,
+ KeyEvent.KEYCODE_BUTTON_L1,
+ KeyEvent.KEYCODE_BUTTON_R1,
+ KeyEvent.KEYCODE_DPAD_UP,
+ KeyEvent.KEYCODE_DPAD_DOWN,
+ KeyEvent.KEYCODE_DPAD_LEFT,
+ KeyEvent.KEYCODE_DPAD_RIGHT,
+ KeyEvent.KEYCODE_BUTTON_SELECT,
+ KeyEvent.KEYCODE_DPAD_CENTER,
+
+ // These don't map into any SDL controller buttons directly
+ KeyEvent.KEYCODE_BUTTON_L2,
+ KeyEvent.KEYCODE_BUTTON_R2,
+ KeyEvent.KEYCODE_BUTTON_C,
+ KeyEvent.KEYCODE_BUTTON_Z,
+ KeyEvent.KEYCODE_BUTTON_1,
+ KeyEvent.KEYCODE_BUTTON_2,
+ KeyEvent.KEYCODE_BUTTON_3,
+ KeyEvent.KEYCODE_BUTTON_4,
+ KeyEvent.KEYCODE_BUTTON_5,
+ KeyEvent.KEYCODE_BUTTON_6,
+ KeyEvent.KEYCODE_BUTTON_7,
+ KeyEvent.KEYCODE_BUTTON_8,
+ KeyEvent.KEYCODE_BUTTON_9,
+ KeyEvent.KEYCODE_BUTTON_10,
+ KeyEvent.KEYCODE_BUTTON_11,
+ KeyEvent.KEYCODE_BUTTON_12,
+ KeyEvent.KEYCODE_BUTTON_13,
+ KeyEvent.KEYCODE_BUTTON_14,
+ KeyEvent.KEYCODE_BUTTON_15,
+ KeyEvent.KEYCODE_BUTTON_16,
+ };
+ int[] masks = new int[] {
+ (1 << 0), // A -> A
+ (1 << 1), // B -> B
+ (1 << 2), // X -> X
+ (1 << 3), // Y -> Y
+ (1 << 4), // BACK -> BACK
+ (1 << 6), // MENU -> START
+ (1 << 5), // MODE -> GUIDE
+ (1 << 6), // START -> START
+ (1 << 7), // THUMBL -> LEFTSTICK
+ (1 << 8), // THUMBR -> RIGHTSTICK
+ (1 << 9), // L1 -> LEFTSHOULDER
+ (1 << 10), // R1 -> RIGHTSHOULDER
+ (1 << 11), // DPAD_UP -> DPAD_UP
+ (1 << 12), // DPAD_DOWN -> DPAD_DOWN
+ (1 << 13), // DPAD_LEFT -> DPAD_LEFT
+ (1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
+ (1 << 4), // SELECT -> BACK
+ (1 << 0), // DPAD_CENTER -> A
+ (1 << 15), // L2 -> ??
+ (1 << 16), // R2 -> ??
+ (1 << 17), // C -> ??
+ (1 << 18), // Z -> ??
+ (1 << 20), // 1 -> ??
+ (1 << 21), // 2 -> ??
+ (1 << 22), // 3 -> ??
+ (1 << 23), // 4 -> ??
+ (1 << 24), // 5 -> ??
+ (1 << 25), // 6 -> ??
+ (1 << 26), // 7 -> ??
+ (1 << 27), // 8 -> ??
+ (1 << 28), // 9 -> ??
+ (1 << 29), // 10 -> ??
+ (1 << 30), // 11 -> ??
+ (1 << 31), // 12 -> ??
+ // We're out of room...
+ 0xFFFFFFFF, // 13 -> ??
+ 0xFFFFFFFF, // 14 -> ??
+ 0xFFFFFFFF, // 15 -> ??
+ 0xFFFFFFFF, // 16 -> ??
+ };
+ boolean[] has_keys = joystickDevice.hasKeys(keys);
+ for (int i = 0; i < keys.length; ++i) {
+ if (has_keys[i]) {
+ button_mask |= masks[i];
+ }
+ }
+ return button_mask;
+ }
+}
+
+class SDLHapticHandler_API31 extends SDLHapticHandler {
+ @Override
+ public void run(int device_id, float intensity, int length) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ vibrate(haptic.vib, intensity, length);
+ }
+ }
+
+ @Override
+ public void rumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) {
+ InputDevice device = InputDevice.getDevice(device_id);
+ if (device == null) {
+ return;
+ }
+
+ VibratorManager manager = device.getVibratorManager();
+ int[] vibrators = manager.getVibratorIds();
+ if (vibrators.length >= 2) {
+ vibrate(manager.getVibrator(vibrators[0]), low_frequency_intensity, length);
+ vibrate(manager.getVibrator(vibrators[1]), high_frequency_intensity, length);
+ } else if (vibrators.length == 1) {
+ float intensity = (low_frequency_intensity * 0.6f) + (high_frequency_intensity * 0.4f);
+ vibrate(manager.getVibrator(vibrators[0]), intensity, length);
+ }
+ }
+
+ private void vibrate(Vibrator vibrator, float intensity, int length) {
+ if (intensity == 0.0f) {
+ vibrator.cancel();
+ return;
+ }
+
+ int value = Math.round(intensity * 255);
+ if (value > 255) {
+ value = 255;
+ }
+ if (value < 1) {
+ vibrator.cancel();
+ return;
+ }
+ try {
+ vibrator.vibrate(VibrationEffect.createOneShot(length, value));
+ }
+ catch (Exception e) {
+ // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
+ // something went horribly wrong with the Android 8.0 APIs.
+ vibrator.vibrate(length);
+ }
+ }
+}
+
+class SDLHapticHandler_API26 extends SDLHapticHandler {
+ @Override
+ public void run(int device_id, float intensity, int length) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ if (intensity == 0.0f) {
+ stop(device_id);
+ return;
+ }
+
+ int vibeValue = Math.round(intensity * 255);
+
+ if (vibeValue > 255) {
+ vibeValue = 255;
+ }
+ if (vibeValue < 1) {
+ stop(device_id);
+ return;
+ }
+ try {
+ haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
+ }
+ catch (Exception e) {
+ // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
+ // something went horribly wrong with the Android 8.0 APIs.
+ haptic.vib.vibrate(length);
+ }
+ }
+ }
+}
+
+class SDLHapticHandler {
+
+ static class SDLHaptic {
+ public int device_id;
+ public String name;
+ public Vibrator vib;
+ }
+
+ private final ArrayList mHaptics;
+
+ public SDLHapticHandler() {
+ mHaptics = new ArrayList();
+ }
+
+ public void run(int device_id, float intensity, int length) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ haptic.vib.vibrate(length);
+ }
+ }
+
+ public void rumble(int device_id, float low_frequency_intensity, float high_frequency_intensity, int length) {
+ // Not supported in older APIs
+ }
+
+ public void stop(int device_id) {
+ SDLHaptic haptic = getHaptic(device_id);
+ if (haptic != null) {
+ haptic.vib.cancel();
+ }
+ }
+
+ public void pollHapticDevices() {
+
+ final int deviceId_VIBRATOR_SERVICE = 999999;
+ boolean hasVibratorService = false;
+
+ /* Check VIBRATOR_SERVICE */
+ Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
+ if (vib != null) {
+ hasVibratorService = vib.hasVibrator();
+
+ if (hasVibratorService) {
+ SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
+ if (haptic == null) {
+ haptic = new SDLHaptic();
+ haptic.device_id = deviceId_VIBRATOR_SERVICE;
+ haptic.name = "VIBRATOR_SERVICE";
+ haptic.vib = vib;
+ mHaptics.add(haptic);
+ SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
+ }
+ }
+ }
+
+ /* Check removed devices */
+ ArrayList removedDevices = null;
+ for (SDLHaptic haptic : mHaptics) {
+ int device_id = haptic.device_id;
+ if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
+ if (removedDevices == null) {
+ removedDevices = new ArrayList();
+ }
+ removedDevices.add(device_id);
+ } // else: don't remove the vibrator if it is still present
+ }
+
+ if (removedDevices != null) {
+ for (int device_id : removedDevices) {
+ SDLControllerManager.nativeRemoveHaptic(device_id);
+ for (int i = 0; i < mHaptics.size(); i++) {
+ if (mHaptics.get(i).device_id == device_id) {
+ mHaptics.remove(i);
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ protected SDLHaptic getHaptic(int device_id) {
+ for (SDLHaptic haptic : mHaptics) {
+ if (haptic.device_id == device_id) {
+ return haptic;
+ }
+ }
+ return null;
+ }
+}
+
+class SDLGenericMotionListener_API14 implements View.OnGenericMotionListener {
+ // Generic Motion (mouse hover, joystick...) events go here
+ @Override
+ public boolean onGenericMotion(View v, MotionEvent event) {
+ if (event.getSource() == InputDevice.SOURCE_JOYSTICK)
+ return SDLControllerManager.handleJoystickMotionEvent(event);
+
+ float x, y;
+ int action = event.getActionMasked();
+ int pointerCount = event.getPointerCount();
+ boolean consumed = false;
+
+ for (int i = 0; i < pointerCount; i++) {
+ int toolType = event.getToolType(i);
+
+ if (toolType == MotionEvent.TOOL_TYPE_MOUSE) {
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, i);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, i);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ consumed = true;
+ break;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ x = getEventX(event, i);
+ y = getEventY(event, i);
+
+ SDLActivity.onNativeMouse(0, action, x, y, checkRelativeEvent(event));
+ consumed = true;
+ break;
+
+ default:
+ break;
+ }
+ } else if (toolType == MotionEvent.TOOL_TYPE_STYLUS || toolType == MotionEvent.TOOL_TYPE_ERASER) {
+ switch (action) {
+ case MotionEvent.ACTION_HOVER_ENTER:
+ case MotionEvent.ACTION_HOVER_MOVE:
+ case MotionEvent.ACTION_HOVER_EXIT:
+ x = event.getX(i);
+ y = event.getY(i);
+ float p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+
+ // BUTTON_STYLUS_PRIMARY is 2^5, so shift by 4, and apply SDL_PEN_INPUT_DOWN/SDL_PEN_INPUT_ERASER_TIP
+ int buttons = (event.getButtonState() >> 4) | (1 << (toolType == MotionEvent.TOOL_TYPE_STYLUS ? 0 : 30));
+
+ SDLActivity.onNativePen(event.getPointerId(i), buttons, action, x, y, p);
+ consumed = true;
+ break;
+ }
+ }
+ }
+
+ return consumed;
+ }
+
+ public boolean supportsRelativeMouse() {
+ return false;
+ }
+
+ public boolean inRelativeMode() {
+ return false;
+ }
+
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ return false;
+ }
+
+ public void reclaimRelativeMouseModeIfNeeded() {
+
+ }
+
+ public boolean checkRelativeEvent(MotionEvent event) {
+ return inRelativeMode();
+ }
+
+ public float getEventX(MotionEvent event, int pointerIndex) {
+ return event.getX(pointerIndex);
+ }
+
+ public float getEventY(MotionEvent event, int pointerIndex) {
+ return event.getY(pointerIndex);
+ }
+
+}
+
+class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API14 {
+ // Generic Motion (mouse hover, joystick...) events go here
+
+ private boolean mRelativeModeEnabled;
+
+ @Override
+ public boolean supportsRelativeMouse() {
+ return true;
+ }
+
+ @Override
+ public boolean inRelativeMode() {
+ return mRelativeModeEnabled;
+ }
+
+ @Override
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ mRelativeModeEnabled = enabled;
+ return true;
+ }
+
+ @Override
+ public float getEventX(MotionEvent event, int pointerIndex) {
+ if (mRelativeModeEnabled && event.getToolType(pointerIndex) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X, pointerIndex);
+ } else {
+ return event.getX(pointerIndex);
+ }
+ }
+
+ @Override
+ public float getEventY(MotionEvent event, int pointerIndex) {
+ if (mRelativeModeEnabled && event.getToolType(pointerIndex) == MotionEvent.TOOL_TYPE_MOUSE) {
+ return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y, pointerIndex);
+ } else {
+ return event.getY(pointerIndex);
+ }
+ }
+}
+
+class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
+ // Generic Motion (mouse hover, joystick...) events go here
+ private boolean mRelativeModeEnabled;
+
+ @Override
+ public boolean supportsRelativeMouse() {
+ return (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */);
+ }
+
+ @Override
+ public boolean inRelativeMode() {
+ return mRelativeModeEnabled;
+ }
+
+ @Override
+ public boolean setRelativeMouseEnabled(boolean enabled) {
+ if (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */) {
+ if (enabled) {
+ SDLActivity.getContentView().requestPointerCapture();
+ } else {
+ SDLActivity.getContentView().releasePointerCapture();
+ }
+ mRelativeModeEnabled = enabled;
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ @Override
+ public void reclaimRelativeMouseModeIfNeeded() {
+ if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
+ SDLActivity.getContentView().requestPointerCapture();
+ }
+ }
+
+ @Override
+ public boolean checkRelativeEvent(MotionEvent event) {
+ return event.getSource() == InputDevice.SOURCE_MOUSE_RELATIVE;
+ }
+
+ @Override
+ public float getEventX(MotionEvent event, int pointerIndex) {
+ // Relative mouse in capture mode will only have relative for X/Y
+ return event.getX(pointerIndex);
+ }
+
+ @Override
+ public float getEventY(MotionEvent event, int pointerIndex) {
+ // Relative mouse in capture mode will only have relative for X/Y
+ return event.getY(pointerIndex);
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLDummyEdit.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLDummyEdit.java
new file mode 100644
index 0000000000..a642854509
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLDummyEdit.java
@@ -0,0 +1,70 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.content.*;
+import android.view.*;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+/* This is a fake invisible editor view that receives the input and defines the
+ * pan&scan region
+ */
+public class SDLDummyEdit extends View implements View.OnKeyListener
+{
+ InputConnection ic;
+ int input_type;
+
+ public SDLDummyEdit(Context context) {
+ super(context);
+ setFocusableInTouchMode(true);
+ setFocusable(true);
+ setOnKeyListener(this);
+ }
+
+ public void setInputType(int input_type) {
+ this.input_type = input_type;
+ }
+
+ @Override
+ public boolean onCheckIsTextEditor() {
+ return true;
+ }
+
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ return SDLActivity.handleKeyEvent(v, keyCode, event, ic);
+ }
+
+ //
+ @Override
+ public boolean onKeyPreIme (int keyCode, KeyEvent event) {
+ // As seen on StackOverflow: http://stackoverflow.com/questions/7634346/keyboard-hide-event
+ // FIXME: Discussion at http://bugzilla.libsdl.org/show_bug.cgi?id=1639
+ // FIXME: This is not a 100% effective solution to the problem of detecting if the keyboard is showing or not
+ // FIXME: A more effective solution would be to assume our Layout to be RelativeLayout or LinearLayout
+ // FIXME: And determine the keyboard presence doing this: http://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android
+ // FIXME: An even more effective way would be if Android provided this out of the box, but where would the fun be in that :)
+ if (event.getAction()==KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK) {
+ if (SDLActivity.mTextEdit != null && SDLActivity.mTextEdit.getVisibility() == View.VISIBLE) {
+ SDLActivity.onNativeKeyboardFocusLost();
+ }
+ }
+ return super.onKeyPreIme(keyCode, event);
+ }
+
+ @Override
+ public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
+ ic = new SDLInputConnection(this, true);
+
+ outAttrs.inputType = input_type;
+ outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
+ EditorInfo.IME_FLAG_NO_FULLSCREEN /* API 11 */;
+
+ return ic;
+ }
+}
+
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLInputConnection.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLInputConnection.java
new file mode 100644
index 0000000000..b7bbfb4aee
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLInputConnection.java
@@ -0,0 +1,142 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+import android.os.Build;
+import android.text.Editable;
+import android.view.*;
+import android.view.inputmethod.BaseInputConnection;
+import android.widget.EditText;
+
+public class SDLInputConnection extends BaseInputConnection
+{
+ protected EditText mEditText;
+ protected String mCommittedText = "";
+
+ public SDLInputConnection(View targetView, boolean fullEditor) {
+ super(targetView, fullEditor);
+ mEditText = new EditText(SDL.getContext());
+ }
+
+ @Override
+ public Editable getEditable() {
+ return mEditText.getEditableText();
+ }
+
+ @Override
+ public boolean sendKeyEvent(KeyEvent event) {
+ /*
+ * This used to handle the keycodes from soft keyboard (and IME-translated input from hardkeyboard)
+ * However, as of Ice Cream Sandwich and later, almost all soft keyboard doesn't generate key presses
+ * and so we need to generate them ourselves in commitText. To avoid duplicates on the handful of keys
+ * that still do, we empty this out.
+ */
+
+ /*
+ * Return DOES still generate a key event, however. So rather than using it as the 'click a button' key
+ * as we do with physical keyboards, let's just use it to hide the keyboard.
+ */
+
+ if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER) {
+ if (SDLActivity.onNativeSoftReturnKey()) {
+ return true;
+ }
+ }
+
+ return super.sendKeyEvent(event);
+ }
+
+ @Override
+ public boolean commitText(CharSequence text, int newCursorPosition) {
+ if (!super.commitText(text, newCursorPosition)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ @Override
+ public boolean setComposingText(CharSequence text, int newCursorPosition) {
+ if (!super.setComposingText(text, newCursorPosition)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ @Override
+ public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+ if (Build.VERSION.SDK_INT <= 29 /* Android 10.0 (Q) */) {
+ // Workaround to capture backspace key. Ref: http://stackoverflow.com/questions>/14560344/android-backspace-in-webview-baseinputconnection
+ // and https://bugzilla.libsdl.org/show_bug.cgi?id=2265
+ if (beforeLength > 0 && afterLength == 0) {
+ // backspace(s)
+ while (beforeLength-- > 0) {
+ nativeGenerateScancodeForUnichar('\b');
+ }
+ return true;
+ }
+ }
+
+ if (!super.deleteSurroundingText(beforeLength, afterLength)) {
+ return false;
+ }
+ updateText();
+ return true;
+ }
+
+ protected void updateText() {
+ final Editable content = getEditable();
+ if (content == null) {
+ return;
+ }
+
+ String text = content.toString();
+ int compareLength = Math.min(text.length(), mCommittedText.length());
+ int matchLength, offset;
+
+ /* Backspace over characters that are no longer in the string */
+ for (matchLength = 0; matchLength < compareLength; ) {
+ int codePoint = mCommittedText.codePointAt(matchLength);
+ if (codePoint != text.codePointAt(matchLength)) {
+ break;
+ }
+ matchLength += Character.charCount(codePoint);
+ }
+ /* FIXME: This doesn't handle graphemes, like '🌬️' */
+ for (offset = matchLength; offset < mCommittedText.length(); ) {
+ int codePoint = mCommittedText.codePointAt(offset);
+ nativeGenerateScancodeForUnichar('\b');
+ offset += Character.charCount(codePoint);
+ }
+
+ if (matchLength < text.length()) {
+ String pendingText = text.subSequence(matchLength, text.length()).toString();
+ if (!SDLActivity.dispatchingKeyEvent()) {
+ for (offset = 0; offset < pendingText.length(); ) {
+ int codePoint = pendingText.codePointAt(offset);
+ if (codePoint == '\n') {
+ if (SDLActivity.onNativeSoftReturnKey()) {
+ return;
+ }
+ }
+ /* Higher code points don't generate simulated scancodes */
+ if (codePoint > 0 && codePoint < 128) {
+ nativeGenerateScancodeForUnichar((char)codePoint);
+ }
+ offset += Character.charCount(codePoint);
+ }
+ }
+ SDLInputConnection.nativeCommitText(pendingText, 0);
+ }
+ mCommittedText = text;
+ }
+
+ public static native void nativeCommitText(String text, int newCursorPosition);
+
+ public static native void nativeGenerateScancodeForUnichar(char c);
+}
+
diff --git a/app_pojavlauncher/src/main/java/org/libsdl/app/SDLSurface.java b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLSurface.java
new file mode 100644
index 0000000000..d77a22e1ae
--- /dev/null
+++ b/app_pojavlauncher/src/main/java/org/libsdl/app/SDLSurface.java
@@ -0,0 +1,412 @@
+/*
+ * This file is part of SDL3 android-project java code.
+ * Licensed under the zlib license: https://www.libsdl.org/license.php
+ */
+
+package org.libsdl.app;
+
+
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.graphics.Insets;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Display;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.WindowInsets;
+import android.view.WindowManager;
+
+
+/**
+ SDLSurface. This is what we draw on, so we need to know when it's created
+ in order to do anything useful.
+
+ Because of this, that's where we set up the SDL thread
+*/
+public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
+ View.OnApplyWindowInsetsListener, View.OnKeyListener, View.OnTouchListener, SensorEventListener {
+
+ // Sensors
+ protected SensorManager mSensorManager;
+ protected Display mDisplay;
+
+ // Keep track of the surface size to normalize touch events
+ protected float mWidth, mHeight;
+
+ // Is SurfaceView ready for rendering
+ public boolean mIsSurfaceReady;
+
+ // Startup
+ public SDLSurface(Context context) {
+ super(context);
+ getHolder().addCallback(this);
+
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ requestFocus();
+ setOnApplyWindowInsetsListener(this);
+ setOnKeyListener(this);
+ setOnTouchListener(this);
+
+ mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+ mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
+
+ setOnGenericMotionListener(SDLActivity.getMotionListener());
+
+ // Some arbitrary defaults to avoid a potential division by zero
+ mWidth = 1.0f;
+ mHeight = 1.0f;
+
+ mIsSurfaceReady = false;
+ }
+
+ public void handlePause() {
+ enableSensor(Sensor.TYPE_ACCELEROMETER, false);
+ }
+
+ public void handleResume() {
+ setFocusable(true);
+ setFocusableInTouchMode(true);
+ requestFocus();
+ setOnApplyWindowInsetsListener(this);
+ setOnKeyListener(this);
+ setOnTouchListener(this);
+ enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+ }
+
+ public Surface getNativeSurface() {
+ return getHolder().getSurface();
+ }
+
+ // Called when we have a valid drawing surface
+ @Override
+ public void surfaceCreated(SurfaceHolder holder) {
+ Log.v("SDL", "surfaceCreated()");
+ SDLActivity.onNativeSurfaceCreated();
+ }
+
+ // Called when we lose the surface
+ @Override
+ public void surfaceDestroyed(SurfaceHolder holder) {
+ Log.v("SDL", "surfaceDestroyed()");
+
+ // Transition to pause, if needed
+ SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
+ SDLActivity.handleNativeState();
+
+ mIsSurfaceReady = false;
+ SDLActivity.onNativeSurfaceDestroyed();
+ }
+
+ // Called when the surface is resized
+ @Override
+ public void surfaceChanged(SurfaceHolder holder,
+ int format, int width, int height) {
+ Log.v("SDL", "surfaceChanged()");
+
+ if (SDLActivity.mSingleton == null) {
+ return;
+ }
+
+ mWidth = width;
+ mHeight = height;
+ int nDeviceWidth = width;
+ int nDeviceHeight = height;
+ float density = 1.0f;
+ try
+ {
+ if (Build.VERSION.SDK_INT >= 17 /* Android 4.2 (JELLY_BEAN_MR1) */) {
+ DisplayMetrics realMetrics = new DisplayMetrics();
+ mDisplay.getRealMetrics( realMetrics );
+ nDeviceWidth = realMetrics.widthPixels;
+ nDeviceHeight = realMetrics.heightPixels;
+ // Use densityDpi instead of density to more closely match what the UI scale is
+ density = (float)realMetrics.densityDpi / 160.0f;
+ }
+ } catch(Exception ignored) {
+ }
+
+ synchronized(SDLActivity.getContext()) {
+ // In case we're waiting on a size change after going fullscreen, send a notification.
+ SDLActivity.getContext().notifyAll();
+ }
+
+ Log.v("SDL", "Window size: " + width + "x" + height);
+ Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
+ SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, density, mDisplay.getRefreshRate());
+ SDLActivity.onNativeResize();
+
+ // Prevent a screen distortion glitch,
+ // for instance when the device is in Landscape and a Portrait App is resumed.
+ boolean skip = false;
+ int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
+
+ if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
+ if (mWidth > mHeight) {
+ skip = true;
+ }
+ } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
+ if (mWidth < mHeight) {
+ skip = true;
+ }
+ }
+
+ // Special Patch for Square Resolution: Black Berry Passport
+ if (skip) {
+ double min = Math.min(mWidth, mHeight);
+ double max = Math.max(mWidth, mHeight);
+
+ if (max / min < 1.20) {
+ Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
+ skip = false;
+ }
+ }
+
+ // Don't skip if we might be multi-window or have popup dialogs
+ if (skip) {
+ if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
+ skip = false;
+ }
+ }
+
+ if (skip) {
+ Log.v("SDL", "Skip .. Surface is not ready.");
+ mIsSurfaceReady = false;
+ return;
+ }
+
+ /* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
+ SDLActivity.onNativeSurfaceChanged();
+
+ /* Surface is ready */
+ mIsSurfaceReady = true;
+
+ SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
+ SDLActivity.handleNativeState();
+ }
+
+ // Window inset
+ @Override
+ public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) {
+ if (Build.VERSION.SDK_INT >= 30 /* Android 11 (R) */) {
+ Insets combined = insets.getInsets(WindowInsets.Type.systemBars() |
+ WindowInsets.Type.systemGestures() |
+ WindowInsets.Type.mandatorySystemGestures() |
+ WindowInsets.Type.tappableElement() |
+ WindowInsets.Type.displayCutout());
+
+ SDLActivity.onNativeInsetsChanged(combined.left, combined.right, combined.top, combined.bottom);
+ }
+
+ // Pass these to any child views in case they need them
+ return insets;
+ }
+
+ // Key events
+ @Override
+ public boolean onKey(View v, int keyCode, KeyEvent event) {
+ return SDLActivity.handleKeyEvent(v, keyCode, event, null);
+ }
+
+ private float getNormalizedX(float x)
+ {
+ if (mWidth <= 1) {
+ return 0.5f;
+ } else {
+ return (x / (mWidth - 1));
+ }
+ }
+
+ private float getNormalizedY(float y)
+ {
+ if (mHeight <= 1) {
+ return 0.5f;
+ } else {
+ return (y / (mHeight - 1));
+ }
+ }
+
+ // Touch events
+ @Override
+ public boolean onTouch(View v, MotionEvent event) {
+ /* Ref: http://developer.android.com/training/gestures/multi.html */
+ int touchDevId = event.getDeviceId();
+ final int pointerCount = event.getPointerCount();
+ int action = event.getActionMasked();
+ int pointerId;
+ int i = 0;
+ float x,y,p;
+
+ if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN)
+ i = event.getActionIndex();
+
+ do {
+ int toolType = event.getToolType(i);
+
+ if (toolType == MotionEvent.TOOL_TYPE_MOUSE) {
+ int buttonState = event.getButtonState();
+ boolean relative = false;
+
+ // We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
+ // if we are. We'll leverage our existing mouse motion listener
+ SDLGenericMotionListener_API14 motionListener = SDLActivity.getMotionListener();
+ x = motionListener.getEventX(event, i);
+ y = motionListener.getEventY(event, i);
+ relative = motionListener.inRelativeMode();
+
+ SDLActivity.onNativeMouse(buttonState, action, x, y, relative);
+ } else if (toolType == MotionEvent.TOOL_TYPE_STYLUS || toolType == MotionEvent.TOOL_TYPE_ERASER) {
+ pointerId = event.getPointerId(i);
+ x = event.getX(i);
+ y = event.getY(i);
+ p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+
+ // BUTTON_STYLUS_PRIMARY is 2^5, so shift by 4, and apply SDL_PEN_INPUT_DOWN/SDL_PEN_INPUT_ERASER_TIP
+ int buttonState = (event.getButtonState() >> 4) | (1 << (toolType == MotionEvent.TOOL_TYPE_STYLUS ? 0 : 30));
+
+ SDLActivity.onNativePen(pointerId, buttonState, action, x, y, p);
+ } else { // MotionEvent.TOOL_TYPE_FINGER or MotionEvent.TOOL_TYPE_UNKNOWN
+ pointerId = event.getPointerId(i);
+ x = getNormalizedX(event.getX(i));
+ y = getNormalizedY(event.getY(i));
+ p = event.getPressure(i);
+ if (p > 1.0f) {
+ // may be larger than 1.0f on some devices
+ // see the documentation of getPressure(i)
+ p = 1.0f;
+ }
+
+ SDLActivity.onNativeTouch(touchDevId, pointerId, action, x, y, p);
+ }
+
+ // Non-primary up/down
+ if (action == MotionEvent.ACTION_POINTER_UP || action == MotionEvent.ACTION_POINTER_DOWN)
+ break;
+ } while (++i < pointerCount);
+
+ return true;
+ }
+
+ // Sensor events
+ public void enableSensor(int sensortype, boolean enabled) {
+ // TODO: This uses getDefaultSensor - what if we have >1 accels?
+ if (enabled) {
+ mSensorManager.registerListener(this,
+ mSensorManager.getDefaultSensor(sensortype),
+ SensorManager.SENSOR_DELAY_GAME, null);
+ } else {
+ mSensorManager.unregisterListener(this,
+ mSensorManager.getDefaultSensor(sensortype));
+ }
+ }
+
+ @Override
+ public void onAccuracyChanged(Sensor sensor, int accuracy) {
+ // TODO
+ }
+
+ @Override
+ public void onSensorChanged(SensorEvent event) {
+ if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
+
+ // Since we may have an orientation set, we won't receive onConfigurationChanged events.
+ // We thus should check here.
+ int newRotation;
+
+ float x, y;
+ switch (mDisplay.getRotation()) {
+ case Surface.ROTATION_0:
+ default:
+ x = event.values[0];
+ y = event.values[1];
+ newRotation = 0;
+ break;
+ case Surface.ROTATION_90:
+ x = -event.values[1];
+ y = event.values[0];
+ newRotation = 90;
+ break;
+ case Surface.ROTATION_180:
+ x = -event.values[0];
+ y = -event.values[1];
+ newRotation = 180;
+ break;
+ case Surface.ROTATION_270:
+ x = event.values[1];
+ y = -event.values[0];
+ newRotation = 270;
+ break;
+ }
+
+ if (newRotation != SDLActivity.mCurrentRotation) {
+ SDLActivity.mCurrentRotation = newRotation;
+ SDLActivity.onNativeRotationChanged(newRotation);
+ }
+
+ SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
+ y / SensorManager.GRAVITY_EARTH,
+ event.values[2] / SensorManager.GRAVITY_EARTH);
+
+
+ }
+ }
+
+ // Captured pointer events for API 26.
+ public boolean onCapturedPointerEvent(MotionEvent event)
+ {
+ int action = event.getActionMasked();
+ int pointerCount = event.getPointerCount();
+
+ for (int i = 0; i < pointerCount; i++) {
+ float x, y;
+ switch (action) {
+ case MotionEvent.ACTION_SCROLL:
+ x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, i);
+ y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, i);
+ SDLActivity.onNativeMouse(0, action, x, y, false);
+ return true;
+
+ case MotionEvent.ACTION_HOVER_MOVE:
+ case MotionEvent.ACTION_MOVE:
+ x = event.getX(i);
+ y = event.getY(i);
+ SDLActivity.onNativeMouse(0, action, x, y, true);
+ return true;
+
+ case MotionEvent.ACTION_BUTTON_PRESS:
+ case MotionEvent.ACTION_BUTTON_RELEASE:
+
+ // Change our action value to what SDL's code expects.
+ if (action == MotionEvent.ACTION_BUTTON_PRESS) {
+ action = MotionEvent.ACTION_DOWN;
+ } else { /* MotionEvent.ACTION_BUTTON_RELEASE */
+ action = MotionEvent.ACTION_UP;
+ }
+
+ x = event.getX(i);
+ y = event.getY(i);
+ int button = event.getButtonState();
+
+ SDLActivity.onNativeMouse(button, action, x, y, true);
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/app_pojavlauncher/src/main/java/org/lwjgl/glfw/CallbackBridge.java b/app_pojavlauncher/src/main/java/org/lwjgl/glfw/CallbackBridge.java
index e60966e600..ca561a47cb 100644
--- a/app_pojavlauncher/src/main/java/org/lwjgl/glfw/CallbackBridge.java
+++ b/app_pojavlauncher/src/main/java/org/lwjgl/glfw/CallbackBridge.java
@@ -2,8 +2,10 @@
import net.kdt.pojavlaunch.*;
import net.kdt.pojavlaunch.customcontrols.gamepad.direct.DirectGamepadEnableHandler;
+import net.kdt.pojavlaunch.prefs.LauncherPreferences;
import android.content.*;
+import android.util.DisplayMetrics;
import android.util.Log;
import android.view.Choreographer;
@@ -56,16 +58,33 @@ public static void sendCursorPos(float x, float y) {
nativeSendCursorPos(mouseX, mouseY);
}
+ /**
+ * Sends keycodes if keycode is populated. Used for in-game controls.
+ * Sends character if keychar is populated. Used for chat and text input.
+ * You can refer to glfwSetKeyCallback for the arguments.
+ * @param keycode LwjglGlfwKeycode
+ * @param keychar Literal char. Modifier keys does not affect this.
+ * @param scancode
+ * @param modifiers The action is one of The action is one of GLFW_PRESS, or GLFW_RELEASE.
+ * We don't have GLFW_REPEAT working.
+ * @param isDown If its being pressed down or not. 1 is true.
+ */
public static void sendKeycode(int keycode, char keychar, int scancode, int modifiers, boolean isDown) {
// TODO CHECK: This may cause input issue, not receive input!
if(keycode != 0) nativeSendKey(keycode,scancode,isDown ? 1 : 0, modifiers);
- if(isDown && keychar != '\u0000') {
+ // Only controlmaps goes through here, that means we need to block ISOControl or else
+ // Minecraft tries to type :TAB: as a character in chat, fails, and then ignores the key,
+ // breaking the tab autofill function in old versions. (like 1.12.2, 1.8.9).
+ if(isDown && !Character.isISOControl(keychar)) {
nativeSendCharMods(keychar,modifiers);
nativeSendChar(keychar);
}
}
public static void sendChar(char keychar, int modifiers){
+ // Only an EditText goes through here, that means emojis are allowed, so no isISOControl
+ // cause we might break emoji mods then.
+ // See net/kdt/pojavlaunch/customcontrols/keyboard/TouchCharInput.java#L147 (onTextChanged)
nativeSendCharMods(keychar,modifiers);
nativeSendChar(keychar);
}
@@ -82,6 +101,10 @@ public static void sendKeyPress(int keyCode, char keyChar, int scancode, int mod
CallbackBridge.sendKeycode(keyCode, keyChar, scancode, modifiers, status);
}
+ public static void sendKeyPress(int keyCode, char keyChar, int modifiers, boolean status) {
+ sendKeyPress(keyCode, keyChar, 0, modifiers, status);
+ }
+
public static void sendKeyPress(int keyCode) {
sendKeyPress(keyCode, CallbackBridge.getCurrentMods(), true);
sendKeyPress(keyCode, CallbackBridge.getCurrentMods(), false);
@@ -225,6 +248,13 @@ public static FloatBuffer createGamepadAxisBuffer() {
public static void setDirectGamepadEnableHandler(DirectGamepadEnableHandler h) {
sDirectGamepadEnableHandler = new WeakReference<>(h);
}
+ @Keep // Used to implement glfwGetWindowContentScale for imgui-java
+ private static float getAndroidDPI(){
+ DisplayMetrics metrics = new DisplayMetrics();
+ metrics.setToDefaults();
+ // Multiply by scale factor because we scale the resolution on this, so we also scale DPI on it
+ return metrics.density * LauncherPreferences.PREF_SCALE_FACTOR;
+ }
@Keep @CriticalNative public static native void nativeSetUseInputStackQueue(boolean useInputStackQueue);
diff --git a/app_pojavlauncher/src/main/jni/Android.mk b/app_pojavlauncher/src/main/jni/Android.mk
index 0763db163b..ca71fa1656 100644
--- a/app_pojavlauncher/src/main/jni/Android.mk
+++ b/app_pojavlauncher/src/main/jni/Android.mk
@@ -1,6 +1,5 @@
LOCAL_PATH := $(call my-dir)
HERE_PATH := $(LOCAL_PATH)
-
# include $(HERE_PATH)/crash_dump/libbase/Android.mk
# include $(HERE_PATH)/crash_dump/libbacktrace/Android.mk
# include $(HERE_PATH)/crash_dump/debuggerd/Android.mk
diff --git a/app_pojavlauncher/src/main/jni/Application.mk b/app_pojavlauncher/src/main/jni/Application.mk
index ce2ec9f16c..9357eda7b6 100644
--- a/app_pojavlauncher/src/main/jni/Application.mk
+++ b/app_pojavlauncher/src/main/jni/Application.mk
@@ -1,4 +1,4 @@
# NDK_TOOLCHAIN_VERSION := 4.9
APP_PLATFORM := android-21
-APP_STL := system
-# APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
+APP_STL := c++_shared
+APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
diff --git a/app_pojavlauncher/src/main/jni/CMakeLists.txt b/app_pojavlauncher/src/main/jni/CMakeLists.txt
new file mode 100644
index 0000000000..10351671bc
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/CMakeLists.txt
@@ -0,0 +1,49 @@
+
+cmake_minimum_required(VERSION 3.22.1)
+
+project("MojoLauncher")
+
+add_library(pojavexec SHARED
+ affinity.c
+ jvm_hooks/emui_iterator_fix_hook.c
+ jvm_hooks/java_exec_hooks.c
+ jvm_hooks/lwjgl_dlopen_hook.c
+ jre_launcher/jre_launcher.c
+ jre_launcher/native_library_hook.c
+ jre_launcher/elf_hinter.c
+ jre_launcher/abort_wait.c
+ driver_helper/nsbypass.c
+ driver_helper/fake_dlfcn.c
+ driver_helper/func_locator.c
+ utils.c
+ stdio_is.c
+ minibridge.c
+ vulkan_loader.c
+
+)
+
+target_include_directories(pojavexec PRIVATE .)
+target_link_libraries(pojavexec PRIVATE log)
+if(CMAKE_ANDROID_ARCH_ABI STREQUAL "arm64-v8a")
+ target_sources(pojavexec PRIVATE driver_helper/arm64_func_locator.c)
+ target_compile_definitions(pojavexec PRIVATE USE_ARM64_LOCATOR)
+ target_compile_definitions(pojavexec PRIVATE ENABLE_TURNIP_LOADER)
+
+ add_library(linkerhook SHARED
+ driver_helper/hook.c
+ )
+ target_link_options(linkerhook PRIVATE "-Wl,-z,global")
+endif()
+
+add_library(pojavexec_awt SHARED
+ awt_bridge.c
+)
+
+add_library(awt_headless SHARED
+ awt_xawt/dummy.c
+)
+
+add_library(awt_xawt SHARED
+ awt_xawt/xawt_fake.c
+)
+target_link_libraries(awt_xawt PRIVATE awt_headless)
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/jni/affinity.c b/app_pojavlauncher/src/main/jni/affinity.c
new file mode 100644
index 0000000000..1991a33787
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/affinity.c
@@ -0,0 +1,67 @@
+//
+// Created by maks on 19.06.2023.
+//
+
+#define _GNU_SOURCE // we are GNU GPLv3
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+static _Thread_local bool big_core_affine = false;
+
+static cpu_set_t bigcore_affinity_set;
+static bool has_affinity_set;
+
+#define FREQ_MAX 256
+void bigcore_format_cpu_path(char* buffer, unsigned int cpu_core) {
+ snprintf(buffer, PATH_MAX, "/sys/devices/system/cpu/cpu%i/cpufreq/cpuinfo_max_freq", cpu_core);
+}
+
+static void create_affinity_set() {
+ char path_buffer[PATH_MAX];
+ char freq_buffer[FREQ_MAX];
+ char* discard;
+ unsigned long core_freq;
+ unsigned long max_freq = 0;
+ unsigned int corecnt = 0;
+ unsigned int big_core_id = 0;
+ while(1) {
+ bigcore_format_cpu_path(path_buffer, corecnt);
+ int corefreqfd = open(path_buffer, O_RDONLY);
+ if(corefreqfd != -1) {
+ ssize_t read_count = read(corefreqfd, freq_buffer, FREQ_MAX);
+ close(corefreqfd);
+ freq_buffer[read_count] = 0;
+ core_freq = strtoul(freq_buffer, &discard, 10);
+ if(core_freq >= max_freq) {
+ max_freq = core_freq;
+ big_core_id = corecnt;
+ }
+ }else{
+ break;
+ }
+ corecnt++;
+ }
+ printf("bigcore: big CPU number is %u, frequency %lu Hz\n", big_core_id, max_freq);
+ CPU_ZERO(&bigcore_affinity_set);
+ CPU_SET_S(big_core_id, CPU_SETSIZE, &bigcore_affinity_set);
+ has_affinity_set = true;
+}
+
+void make_big_core_affine() {
+ if(big_core_affine) return;
+ if(!has_affinity_set) create_affinity_set();
+ int result = sched_setaffinity(0, CPU_SETSIZE, &bigcore_affinity_set);
+ if(result != 0) {
+ printf("bigcore: setting affinity failed: %s\n", strerror(result));
+ }else{
+ printf("bigcore: forced current thread onto big core\n");
+ big_core_affine = true;
+ }
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/jni/awt_bridge.c b/app_pojavlauncher/src/main/jni/awt_bridge.c
index 27bcc65008..9209d8747b 100644
--- a/app_pojavlauncher/src/main/jni/awt_bridge.c
+++ b/app_pojavlauncher/src/main/jni/awt_bridge.c
@@ -117,7 +117,7 @@ JNIEXPORT jintArray JNICALL Java_net_kdt_pojavlaunch_utils_JREUtils_renderAWTScr
androidRgbArray = (*env)->NewIntArray(env, arrayLength);
(*env)->SetIntArrayRegion(env, androidRgbArray, 0, arrayLength, rgbArray);
- (*runtimeJNIEnvPtr_GRAPHICS)->ReleaseIntArrayElements(runtimeJNIEnvPtr_GRAPHICS, jreRgbArray, rgbArray, NULL);
+ (*runtimeJNIEnvPtr_GRAPHICS)->ReleaseIntArrayElements(runtimeJNIEnvPtr_GRAPHICS, jreRgbArray, rgbArray, 0);
// (*env)->DeleteLocalRef(env, androidRgbArray);
// free(rgbArray);
diff --git a/app_pojavlauncher/src/main/jni/awt_xawt/dummy.c b/app_pojavlauncher/src/main/jni/awt_xawt/dummy.c
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/app_pojavlauncher/src/main/jni/ctxbridges/egl_loader.c b/app_pojavlauncher/src/main/jni/ctxbridges/egl_loader.c
index d64e400b07..7ccb9d97e2 100644
--- a/app_pojavlauncher/src/main/jni/ctxbridges/egl_loader.c
+++ b/app_pojavlauncher/src/main/jni/ctxbridges/egl_loader.c
@@ -4,6 +4,7 @@
#include
#include
#include
+#include
#include "egl_loader.h"
#include "loader_dlopen.h"
@@ -32,7 +33,14 @@ EGLBoolean (*eglQuerySurface_p)( EGLDisplay display,
__eglMustCastToProperFunctionPointerType (*eglGetProcAddress_p) (const char *procname);
bool dlsym_EGL() {
- void* dl_handle = loader_dlopen(getenv("POJAVEXEC_EGL"),"libEGL.so", RTLD_LOCAL|RTLD_LAZY);
+ char* gles = getenv("LIBGL_GLES");
+ char* eglName = (strncmp(gles ? gles : "", "libGLESv2_angle.so", 18) == 0) ? "libEGL_angle.so" : getenv("POJAVEXEC_EGL");
+ // Kopper needs this
+ if (eglName != NULL && strncmp(eglName, "libEGL_mesa.so", 14) == 0) {
+ void* cutils_handle = loader_dlopen("libcutils.so", "libcutils.so", RTLD_GLOBAL|RTLD_NOW);
+ if(cutils_handle == NULL) return false;
+ }
+ void* dl_handle = loader_dlopen(eglName,"libEGL.so", RTLD_LOCAL|RTLD_LAZY);
if(dl_handle == NULL) return false;
eglGetProcAddress_p = dlsym(dl_handle, "eglGetProcAddress");
if(eglGetProcAddress_p == NULL) {
diff --git a/app_pojavlauncher/src/main/jni/ctxbridges/gl_bridge.c b/app_pojavlauncher/src/main/jni/ctxbridges/gl_bridge.c
index 0b9ad99a42..7f3571bac1 100644
--- a/app_pojavlauncher/src/main/jni/ctxbridges/gl_bridge.c
+++ b/app_pojavlauncher/src/main/jni/ctxbridges/gl_bridge.c
@@ -11,6 +11,7 @@
#include "egl_loader.h"
#define TAG __FILE_NAME__
+#include
#include
//
@@ -77,7 +78,7 @@ gl_render_window_t* gl_init_context(gl_render_window_t *share) {
{
EGLBoolean bindResult;
- if (strncmp(getenv("POJAV_RENDERER"), "opengles3_desktopgl", 19) == 0) {
+ if (strncmp(getenv("AMETHYST_RENDERER"), "opengles3_desktopgl", 19) == 0) {
printf("EGLBridge: Binding to desktop OpenGL\n");
bindResult = eglBindAPI_p(EGL_OPENGL_API);
} else {
@@ -101,10 +102,33 @@ gl_render_window_t* gl_init_context(gl_render_window_t *share) {
}
void gl_swap_surface(gl_render_window_t* bundle) {
- if(bundle->nativeSurface != NULL) {
- ANativeWindow_release(bundle->nativeSurface);
+ /*
+ * In some cases (see MinecraftGLSurface.start(), android kills the surface automatically for
+ * us, if we try to release/destroy it, we SIGSEGV. Check if we are -19x-19 or some other
+ * invalid value and skip the release because Android decided to handle releasing it for us.
+ * This goes against every piece of documentation I have ever seen but who actually reads those?
+ *
+ * Some drivers take forever to properly destroy the surface, they do it part at a time or
+ * some other garbage while SIGSEGVing us if we try releasing while they're in the middle of
+ * turning the surface dead. This makes the width and height make it look valid when it actually
+ * isn't so we wait for them and hope there is no race condition of both us and Android trying
+ * to release the surface. This seems driver dependent as AVD and Waydroid do not need 0.75s
+ * to set the bloody height and width to their proper values. They just do it, instantly.
+ */
+ usleep(750000); // An overkill amount of time to wait for a surface to finish dying
+ int32_t nativeWindowWidth = ANativeWindow_getWidth(pojav_environ->pojavWindow);
+ int32_t nativeWindowHeight = ANativeWindow_getHeight(pojav_environ->pojavWindow);
+ if ((nativeWindowWidth > 0) || (nativeWindowHeight > 0)) {
+ LOGI("Native surface dimensions (%d x %d)\n",
+ nativeWindowWidth, nativeWindowHeight);
+ if (bundle->nativeSurface != NULL) {
+ ANativeWindow_release(bundle->nativeSurface);
+ }
+ if (bundle->surface != NULL) eglDestroySurface_p(g_EglDisplay, bundle->surface);
+ } else {
+ LOGW("Native surface dimensions (%d x %d) are invalid! Assuming given nativeSurface is bad.\n",
+ nativeWindowWidth, nativeWindowHeight);
}
- if(bundle->surface != NULL) eglDestroySurface_p(g_EglDisplay, bundle->surface);
if(bundle->newNativeSurface != NULL) {
LOGI("Switching to new native surface");
bundle->nativeSurface = bundle->newNativeSurface;
diff --git a/app_pojavlauncher/src/main/jni/driver_helper/android_namespace_func.h b/app_pojavlauncher/src/main/jni/driver_helper/android_namespace_func.h
new file mode 100644
index 0000000000..5b967302e6
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/driver_helper/android_namespace_func.h
@@ -0,0 +1,32 @@
+//
+// Created by maks on 11.05.2026.
+//
+
+#ifndef POJAVLAUNCHER_ANDROID_NAMESPACE_FUNC_H
+#define POJAVLAUNCHER_ANDROID_NAMESPACE_FUNC_H
+
+#include
+#include
+
+typedef struct android_namespace_t* (*android_create_namespace_t)(
+ const char* name, const char* ld_library_path, const char* default_library_path, uint64_t type,
+ const char* permitted_when_isolated_path, struct android_namespace_t* parent);
+
+typedef void* (*android_link_namespaces_t)(struct android_namespace_t* namespace_from,
+ struct android_namespace_t* namespace_to,
+ const char* shared_libs_sonames);
+
+typedef void* (*loader_dlopen_t)(const char* filename, int flags, const void* caller_addr);
+
+typedef int (*dlclose_impl)(void* handle);
+
+typedef struct {
+ void* dl_handle;
+ dlclose_impl close;
+ android_create_namespace_t create_namespace;
+ android_link_namespaces_t link_namespaces;
+} android_ldfuncs_t;
+
+bool locate_namespace_funcs(android_ldfuncs_t* funcs);
+
+#endif //POJAVLAUNCHER_ANDROID_NAMESPACE_FUNC_H
diff --git a/app_pojavlauncher/src/main/jni/driver_helper/arm64_func_locator.c b/app_pojavlauncher/src/main/jni/driver_helper/arm64_func_locator.c
new file mode 100644
index 0000000000..e20a61b522
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/driver_helper/arm64_func_locator.c
@@ -0,0 +1,53 @@
+//
+// Created by maks on 11.05.2026.
+//
+
+#include
+#include
+#include
+#include
+#include
+#include "android_namespace_func.h"
+
+#define TAG __FILE_NAME__
+#include
+
+/* upper 6 bits of an ARM64 instruction are the instruction name */
+#define OP_MS 0b11111100000000000000000000000000
+/* Branch Label instruction opcode and immediate mask */
+#define BL_OP 0b10010100000000000000000000000000
+#define BL_IM 0b00000011111111111111111111111111
+
+#define PAGE_ROUND_DOWN(X) (void*)(((uintptr_t)X) & ~(pagesize-1))
+
+// Find the first "branch to label" function in the function provided in func_start
+static void* find_branch_label(int pagesize, void* func_start) {
+ // round down the pointer to get the start of the function's page
+ void* func_page_start = PAGE_ROUND_DOWN(func_start);
+ // remap to r-x to bypass "execute only" protections on MIUI
+ mprotect(func_page_start, pagesize, PROT_READ | PROT_EXEC);
+ uint32_t* bl_addr = func_start;
+ // search for the "branch to label" opcode
+ while((*bl_addr & OP_MS) != BL_OP) {
+ bl_addr++; // walk through memory until we find it or die
+ }
+ // offset the address to find where the "branch to label" instrunction
+ // points to.
+ return ((char*)bl_addr) + (*bl_addr & BL_IM) * 4;
+}
+
+void* arm64l_locate_libdl_android() {
+ int pagesize = getpagesize();
+ loader_dlopen_t loader_dlopen = find_branch_label(pagesize, &dlopen);
+ if(loader_dlopen == NULL) return NULL;
+ LOGI("loader_dlopen: %p", loader_dlopen);
+ void* loader_dlopen_page_start = PAGE_ROUND_DOWN(loader_dlopen);
+ // reprotecting the functions removes protection from indirect jumps
+ if(mprotect(loader_dlopen_page_start, pagesize, PROT_READ | PROT_EXEC) != 0) {
+ LOGE("failed to strip indirect jump prot: %i", errno);
+ return NULL;
+ }
+ void *dl_android_handle = loader_dlopen("libdl_android.so", RTLD_NOW, &dlopen);
+ if(!dl_android_handle) LOGE("failed: %s", dlerror());
+ return dl_android_handle;
+}
\ No newline at end of file
diff --git a/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.c b/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.c
new file mode 100644
index 0000000000..6e4e4f22ad
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.c
@@ -0,0 +1,172 @@
+//
+// Created by maks on 11.05.2026.
+//
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#define TAG __FILE_NAME__
+#include
+
+#define LOG_DBG
+
+#ifdef LOG_DBG
+#define log_dbg LOGI
+#else
+#define log_dbg(...)
+#endif
+
+struct ctx {
+ void *load_addr;
+ void *dynstr;
+ void *dynsym;
+ ELF_XWORD nsyms;
+ off_t bias;
+};
+
+int fake_dlclose(void *handle)
+{
+ if(handle) {
+ struct ctx *ctx = (struct ctx *) handle;
+ if(ctx->dynsym) free(ctx->dynsym); /* we're saving dynsym and dynstr */
+ if(ctx->dynstr) free(ctx->dynstr); /* from library file just in case */
+ free(ctx);
+ }
+ return 0;
+}
+
+
+/* flags are ignored */
+
+void *fake_dlopen(const char *libpath, int flags)
+{
+ FILE *maps;
+ char buff[1024];
+ struct ctx *ctx = 0;
+ off_t load_addr, size;
+ int k, fd = -1, found = 0;
+ void *shoff;
+ ELF_EHDR *elf = MAP_FAILED;
+
+#define fatal(fmt,args...) do { LOGE(fmt,##args); goto err_exit; } while(0)
+ maps = fopen("/proc/self/maps", "r");
+ if(!maps) fatal("failed to open maps");
+
+ while(!found && fgets(buff, sizeof(buff), maps))
+ if(strstr(buff,libpath)) found = 1;
+
+ fclose(maps);
+
+ if(!found) fatal("%s not found in my userspace", libpath);
+
+ if(sscanf(buff, "%lx", &load_addr) != 1)
+ fatal("failed to read load address for %s", libpath);
+
+ if(libpath[0] != '/') { //not a full path
+ char* name_start = strstr(buff,libpath); // find the name start again
+ while(name_start > buff) { // while we are in the bounds of our buffer
+ if(*name_start == ' ' && *(name_start+1) == '/') { // search for a space preceeding the salsh
+ libpath = name_start+1; //this is where the full name is
+ }
+ name_start--;
+ }
+ char* name_end = strchr(libpath, '\n');
+ if(name_end != NULL) *name_end = 0;
+ }
+ LOGI("%s loaded in Android at 0x%08lx", libpath, load_addr);
+ /* Now, mmap the same library once again */
+
+ fd = open(libpath, O_RDONLY);
+ if(fd < 0) fatal("failed to open %s %s", libpath, strerror(errno));
+
+ size = lseek(fd, 0, SEEK_END);
+ if(size <= 0) fatal("lseek() failed for %s", libpath);
+
+ elf = (ELF_EHDR *) mmap(0, size, PROT_READ, MAP_SHARED, fd, 0);
+ close(fd);
+ fd = -1;
+
+ if(elf == MAP_FAILED) fatal("mmap() failed for %s", libpath);
+
+ ctx = (struct ctx *) calloc(1, sizeof(struct ctx));
+ if(!ctx) fatal("no memory for %s", libpath);
+
+ ctx->load_addr = (void *) load_addr;
+ shoff = ((void *) elf) + elf->e_shoff;
+
+ for(k = 0; k < elf->e_shnum; k++, shoff += elf->e_shentsize) {
+
+ ELF_SHDR *sh = (ELF_SHDR *) shoff;
+ log_dbg("%s: k=%d shdr=%p type=%x", __func__, k, sh, sh->sh_type);
+
+ switch(sh->sh_type) {
+
+ case SHT_DYNSYM:
+ if(ctx->dynsym) fatal("%s: duplicate DYNSYM sections", libpath); /* .dynsym */
+ ctx->dynsym = malloc(sh->sh_size);
+ if(!ctx->dynsym) fatal("%s: no memory for .dynsym", libpath);
+ memcpy(ctx->dynsym, ((void *) elf) + sh->sh_offset, sh->sh_size);
+ ctx->nsyms = (sh->sh_size/sizeof(ELF_SYM)) ;
+ break;
+
+ case SHT_STRTAB:
+ if(ctx->dynstr) break; /* .dynstr is guaranteed to be the first STRTAB */
+ ctx->dynstr = malloc(sh->sh_size);
+ if(!ctx->dynstr) fatal("%s: no memory for .dynstr", libpath);
+ memcpy(ctx->dynstr, ((void *) elf) + sh->sh_offset, sh->sh_size);
+ break;
+
+ case SHT_PROGBITS:
+ if(!ctx->dynstr || !ctx->dynsym) break;
+ /* won't even bother checking against the section name */
+ ctx->bias = (off_t) sh->sh_addr - (off_t) sh->sh_offset;
+ k = elf->e_shnum; /* exit for */
+ break;
+ }
+ }
+
+ munmap(elf, size);
+ elf = 0;
+
+ if(!ctx->dynstr || !ctx->dynsym) fatal("dynamic sections not found in %s", libpath);
+
+#undef fatal
+
+ log_dbg("%s: ok, dynsym = %p, dynstr = %p", libpath, ctx->dynsym, ctx->dynstr);
+
+ return ctx;
+
+ err_exit:
+ if(fd >= 0) close(fd);
+ if(elf != MAP_FAILED) munmap(elf, size);
+ fake_dlclose(ctx);
+ return 0;
+}
+
+void *fake_dlsym(void *handle, const char *name)
+{
+ int k;
+ struct ctx *ctx = (struct ctx *) handle;
+ ELF_SYM *sym = (ELF_SYM *) ctx->dynsym;
+ char *strings = (char *) ctx->dynstr;
+
+ for(k = 0; k < ctx->nsyms; k++, sym++)
+ if(strcmp(strings + sym->st_name, name) == 0) {
+ /* NB: sym->st_value is an offset into the section for relocatables,
+ but a VMA for shared libs or exe files, so we have to subtract the bias */
+ void *ret = ctx->load_addr + sym->st_value - ctx->bias;
+ LOGI("%s found at %p", name, ret);
+ return ret;
+ }
+ return 0;
+}
+
+
diff --git a/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.h b/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.h
new file mode 100644
index 0000000000..5fb8daccbd
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/driver_helper/fake_dlfcn.h
@@ -0,0 +1,12 @@
+//
+// Created by maks on 11.05.2026.
+//
+
+#ifndef POJAVLAUNCHER_FAKE_DLFCN_H
+#define POJAVLAUNCHER_FAKE_DLFCN_H
+
+int fake_dlclose(void *handle);
+void *fake_dlopen(const char *libpath, int flags);
+void *fake_dlsym(void *handle, const char *name);
+
+#endif //POJAVLAUNCHER_FAKE_DLFCN_H
diff --git a/app_pojavlauncher/src/main/jni/driver_helper/func_locator.c b/app_pojavlauncher/src/main/jni/driver_helper/func_locator.c
new file mode 100644
index 0000000000..6ad7a1d9fb
--- /dev/null
+++ b/app_pojavlauncher/src/main/jni/driver_helper/func_locator.c
@@ -0,0 +1,84 @@
+//
+// Created by maks on 11.05.2026.
+//
+
+#include "android_namespace_func.h"
+#include "fake_dlfcn.h"
+#include