diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ea6795535..169a45bd4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -28,12 +28,6 @@ jobs: distribution: 'temurin' java-version: '21' - - name: Get LTW - run: | - apt update && apt install wget - cd app_pojavlauncher/libs - wget https://github.com/AngelAuraMC/LTW/releases/latest/download/ltw-release.aar - - name: Get JRE 8 uses: dawidd6/action-download-artifact@v9 with: @@ -66,6 +60,7 @@ jobs: - uses: gradle/actions/setup-gradle@v4 with: + validate-wrappers: false gradle-version: "8.11" - name: Build JRE JAR files @@ -76,12 +71,6 @@ jobs: # Build JRE JAR files (security manager, etc...) gradle :jre_lwjgl3glfw:build --no-daemon - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - distribution: 'temurin' - java-version: '17' - - name: Build Google Play .aab if: github.repository_owner == 'PojavLauncherTeam' && github.ref_name == 'v3_openjdk' run: | diff --git a/.gitmodules b/.gitmodules index 8549fb41d..912b8fdd3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,9 @@ [submodule "MobileGlues"] path = MobileGlues url = https://github.com/MobileGL-Dev/MobileGlues.git +[submodule "SDL"] + path = app_pojavlauncher/src/main/jni/SDL + url = https://github.com/libsdl-org/SDL.git +[submodule "sdl2-compat"] + path = app_pojavlauncher/src/main/jni/sdl2-compat + url = https://github.com/libsdl-org/sdl2-compat.git diff --git a/README.md b/README.md index ff1c9ff87..dd5e66b9f 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Android CI](https://github.com/AngelAuraMC/Amethyst-Android/workflows/Android%20CI/badge.svg)](https://github.com/AngelAuraMC/Amethyst-Android/actions) [![GitHub commit activity](https://img.shields.io/github/commit-activity/m/AngelAuraMC/Amethyst-Android)](https://github.com/AngelAuraMC/Amethyst-Android/actions) -[![Crowdin](https://badges.crowdin.net/amethyst/localized.svg)](https://crowdin.com/project/amethyst) +[![Crowdin](https://badges.crowdin.net/pojavlauncher/localized.svg)](https://crowdin.com/project/pojavlauncher) [![Discord](https://img.shields.io/discord/724163890803638273.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/5ptqkyZxEy) *From [Boardwalk](https://github.com/zhuowei/Boardwalk)'s ashes and [PojavLauncher](https://github.com/PojavLauncherTeam/PojavLauncher)'s ruined reputation, here comes Amethyst!* @@ -40,7 +40,7 @@ For more details, check out our [wiki](https://angelauramc.dev/wiki)! You can get Amethyst via two methods: -1. **Releases:** Download the prebuilt app from our [stable releases](https://github.com/AngelAuraMC/Amethyst-Android/releases) or [automatic builds](https://github.com/AngelAuraMC/Amethyst-Android/actions). +1. **Releases:** Download the latest prebuilt app from [nightly.link](https://nightly.link/AngelAuraMC/Amethyst-Android/workflows/android/v3_openjdk/app-debug.zip) or select an older version from our [automatic builds](https://github.com/AngelAuraMC/Amethyst-Android/actions). 2. **Build from Source:** Follow the [building instructions](#building) below. ## Building @@ -123,7 +123,7 @@ PojavLauncher is licensed under [GNU LGPLv3](https://github.com/AngelAuraMC/Amet * Android Support Libraries: [Apache License 2.0](https://android.googlesource.com/platform/prebuilts/maven_repo/android/+/master/NOTICE.txt). * [GL4ES](https://github.com/AngelAuraMC/gl4es): [MIT License](https://github.com/ptitSeb/gl4es/blob/master/LICENSE). * [MobileGlues](https://github.com/MobileGL-Dev/MobileGlues): [LGPL-2.1 License](https://github.com/MobileGL-Dev/MobileGlues/blob/dev-es/LICENSE). -* [ANGLE](https://chromium.googlesource.com/angle/angle): [All Rights Reserved](app_pojavlauncher/src/main/jniLibs/ANGLE_LICENSE). +* [ANGLE](https://chromium.googlesource.com/angle/angle): [All Rights Reserved](app_pojavlauncher/src/main/assets/licenses/ANGLE_LICENSE). * [OpenJDK](https://github.com/AngelAuraMC/openjdk-multiarch-jdk8u): [GNU GPLv2 License](https://openjdk.java.net/legal/gplv2+ce.html). * [LWJGL3](https://github.com/AngelAuraMC/lwjgl3): [BSD-3 License](https://github.com/LWJGL/lwjgl3/blob/master/LICENSE.md). * [LWJGLX](https://github.com/AngelAuraMC/lwjglx) (LWJGL2 API compatibility layer for LWJGL3): unknown license. @@ -132,6 +132,11 @@ PojavLauncher is licensed under [GNU LGPLv3](https://github.com/AngelAuraMC/Amet * [bhook](https://github.com/bytedance/bhook) (Used for exit code trapping): [MIT license](https://github.com/bytedance/bhook/blob/main/LICENSE). * [libepoxy](https://github.com/anholt/libepoxy): [MIT License](https://github.com/anholt/libepoxy/blob/master/COPYING). * [virglrenderer](https://github.com/AngelAuraMC/virglrenderer): [MIT License](https://gitlab.freedesktop.org/virgl/virglrenderer/-/blob/master/COPYING). +* [OpenAL-Soft](https://github.com/kcat/openal-soft): [GNU GPLv2](app_pojavlauncher/src/main/assets/licenses/OPENAL-SOFT_GPL2) + * [oboe](https://github.com/google/oboe): [Apache License 2.0](app_pojavlauncher/src/main/assets/licenses/OBOE_APACHE2). + * [pfffft](https://bitbucket.org/jpommier/pffft/src/master/): [ARR](app_pojavlauncher/src/main/assets/licenses/PFFFT_LICENSE) +* [SDL3](https://github.com/libsdl-org/SDL): [zlib License](https://github.com/libsdl-org/SDL/blob/main/LICENSE.txt) +* [sdl2-compat](https://github.com/libsdl-org/sdl2-compat): [zlib License](https://github.com/libsdl-org/sdl2-compat/blob/main/LICENSE.txt) * Thanks to [MCHeads](https://mc-heads.net) for providing Minecraft avatars. ## Roadmap diff --git a/app_pojavlauncher/build.gradle b/app_pojavlauncher/build.gradle index a6421dc10..99caaa7bf 100644 --- a/app_pojavlauncher/build.gradle +++ b/app_pojavlauncher/build.gradle @@ -228,6 +228,8 @@ dependencies { // implementation 'net.sourceforge.streamsupport:streamsupport-cfuture:1.7.0' + implementation 'top.fifthlight.touchcontroller:proxy-client-android:0.0.4' + implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar']) implementation project(":MobileGlues") diff --git a/app_pojavlauncher/src/main/AndroidManifest.xml b/app_pojavlauncher/src/main/AndroidManifest.xml index 7cd3261d0..a25fb0d92 100644 --- a/app_pojavlauncher/src/main/AndroidManifest.xml +++ b/app_pojavlauncher/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + { mLogTextView.setVisibility(isChecked ? VISIBLE : GONE); if(isChecked) { - Logger.setLogListener(mLogListener); + Logger.addLogListener(mLogListener); }else{ mLogTextView.setText(""); - Logger.setLogListener(null); // 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 :) + Logger.removeLogListener(mLogListener); } }); mLogToggle.setChecked(false); diff --git a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java index 0c576ade2..479390e64 100644 --- a/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java +++ b/app_pojavlauncher/src/main/java/com/kdt/mcgui/mcAccountSpinner.java @@ -9,6 +9,7 @@ import android.graphics.Color; import android.graphics.Paint; import android.graphics.drawable.BitmapDrawable; import android.graphics.drawable.Drawable; +import android.net.NetworkInfo; import android.net.Uri; import android.util.AttributeSet; import android.util.Log; @@ -47,6 +48,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Objects; import fr.spse.extended_view.ExtendedTextView; @@ -276,6 +278,10 @@ public class mcAccountSpinner extends AppCompatSpinner implements AdapterView.On } 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)); @@ -296,14 +302,23 @@ public class mcAccountSpinner extends AppCompatSpinner implements AdapterView.On 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 { 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 5fce0dcbc..47af3319f 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 class EfficientAndroidLWJGLKeycode { 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 class EfficientAndroidLWJGLKeycode { 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/LauncherActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java index 9b8834d17..8498e93fb 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/LauncherActivity.java @@ -1,6 +1,8 @@ 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; @@ -139,7 +141,7 @@ public class LauncherActivity extends BaseActivity { } if (isOlderThan13) { - Toast.makeText(this, R.string.toast_not_available_demo, Toast.LENGTH_LONG).show(); + hasNoOnlineProfileDialog(this, getString(R.string.global_error), getString(R.string.demo_versions_supported)); return false; } } @@ -164,7 +166,9 @@ public class LauncherActivity extends BaseActivity { }; private ActivityResultLauncher mRequestNotificationPermissionLauncher; + private ActivityResultLauncher mRequestMicrophonePermissionLauncher; private WeakReference mRequestNotificationPermissionRunnable; + private WeakReference mRequestMicrophonePermissionRunnable; @Override protected boolean shouldIgnoreNotch() { @@ -204,6 +208,16 @@ public class LauncherActivity extends BaseActivity { } } ); + 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(); @@ -341,6 +355,11 @@ public class LauncherActivity extends BaseActivity { 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; @@ -350,6 +369,13 @@ public class LauncherActivity extends BaseActivity { 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); 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 bcd5fb477..fdaa77222 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Logger.java @@ -1,12 +1,19 @@ package net.kdt.pojavlaunch; +import android.util.Log; + import androidx.annotation.Keep; +import java.util.ArrayList; + /** 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 ArrayList logListeners; + private static boolean nativeLogListenerSet = false; + /** Print the text to the log file if not censored */ public static native void appendToLog(String text); @@ -14,12 +21,39 @@ public class Logger { /** 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) { + if (Logger.logListeners == null) Logger.logListeners = new ArrayList<>(); + Logger.logListeners.add(logListeners); + if (Logger.nativeLogListenerSet) return; + + setLogListener(text -> { + for (Logger.eventLogListener logListener: Logger.logListeners) { + logListener.onEventLogged(text); + } + }); + Logger.nativeLogListenerSet = true; + } + /** Remove a listener for the logfile, unset the native listener if no listeners left */ + public static void removeLogListener(eventLogListener logListener) { + if (Logger.logListeners == null) return; + 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); + Logger.nativeLogListenerSet = false; + } + } + + /** 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/MainActivity.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java index 965152e19..288812494 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/MainActivity.java @@ -2,6 +2,7 @@ package net.kdt.pojavlaunch; import static net.kdt.pojavlaunch.Tools.currentDisplayMetrics; import static net.kdt.pojavlaunch.Tools.dialogForceClose; +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; @@ -25,13 +26,11 @@ import android.net.Uri; 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; @@ -63,20 +62,26 @@ import net.kdt.pojavlaunch.prefs.QuickSettingSideDialog; 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; @@ -102,8 +107,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 @@ -283,6 +323,7 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe mQuickSettingSideDialog.cancel(); } CallbackBridge.nativeSetWindowAttrib(LwjglGlfwKeycode.GLFW_HOVERED, 0); + super.onPause(); } @@ -309,7 +350,6 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe @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] @@ -349,9 +389,50 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe } private void runCraft(String versionId, JMinecraftVersionList.Version version) throws Throwable { - LauncherPreferences.writeMGRendererSettings(); // No MG detection for you - if(Tools.LOCAL_RENDERER == null) { - Tools.LOCAL_RENDERER = LauncherPreferences.PREF_RENDERER; + String assetVersion; + 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; + } + // Autoselect renderer + if (Tools.LOCAL_RENDERER == null) { + // 25w09a is when HolyGL4ES starts showing a black screen upon world load. + // There is no way to consistently check for that without breaking mod loaders + // for old versions like legacy fabric so we start from 25w07a instead + + // 25w07a assets and assetIndex.id is set to 23, 25w08a and 25w09a is 24. + + // 1.19.3 snapshots and all future versions restarted assets and assetsIndex.id + // to 1 and started counting up from there + + // Previous versions had "1.19" and "1.18" and such, with April Fools versions + // being even more inconsistent like "3D Shareware v1.34" for the 2019 April Fools + // or 1.RV-Pre1 for 2016, thankfully now they don't seem to do that anymore and just + // use the incrementing system they now have + + // I could probably read the manifest itself then check which position the `id` field is + // and count from there since its ordered latest to oldest but that uses way more code + // for basically 3 peoples benefit + try { + int assetID = Integer.parseInt(assetVersion); + // Check if below 25w08a + Tools.LOCAL_RENDERER = (assetID <= 23) ? "opengles2" : "opengles_mobileglues"; + // Then assume 1.19.2 and below + } catch (NumberFormatException e) { Tools.LOCAL_RENDERER = "opengles2"; } } if(!Tools.checkRendererCompatible(this, Tools.LOCAL_RENDERER)) { Tools.RenderersList renderersList = Tools.getCompatibleRenderers(this); @@ -360,9 +441,22 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe Tools.LOCAL_RENDERER = firstCompatibleRenderer; 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); + if(Tools.LOCAL_RENDERER.equals("opengles_mobileglues")) { + LauncherPreferences.writeMGRendererSettings(); + } JREUtils.redirectAndPrintJRELog(); LauncherProfiles.load(); int requiredJavaVersion = 8; @@ -576,4 +670,22 @@ public class MainActivity extends BaseActivity implements ControlButtonMenuListe return minecraftGLView.dispatchCapturedPointerEvent(ev); else return super.dispatchTrackballEvent(ev); } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (hasFocus) { + Tools.setFullscreen(this, setFullscreen()); + } + super.onWindowFocusChanged(hasFocus); + } + + @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 5e84141d9..b9ea735b3 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,7 @@ package net.kdt.pojavlaunch; import static net.kdt.pojavlaunch.MainActivity.touchCharInput; +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; @@ -39,9 +40,13 @@ import net.kdt.pojavlaunch.customcontrols.mouse.TouchEventProcessor; 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 +82,14 @@ 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; public MinecraftGLSurface(Context context) { this(context, null); @@ -92,6 +99,7 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame super(context, attributeSet); setFocusable(true); CallbackBridge.setDirectGamepadEnableHandler(this); + SDLControllerManager.setDirectGamepadEnableHandler(this); } @RequiresApi(api = Build.VERSION_CODES.O) @@ -192,6 +200,13 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame 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 +217,17 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame 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 +236,18 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame @SuppressLint("NewApi") @Override public boolean dispatchGenericMotionEvent(MotionEvent event) { + if(sdlEnabled && Gamepad.isGamepadEvent(event)) { + try { + MainActivity.motionListener.onGenericMotion(this, event); + return true; + } catch (Throwable ignored){ + Log.e(TAG, "SDL failed to send motionevent!"); + } + } + 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 +264,9 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame // 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 +318,18 @@ public class MinecraftGLSurface extends View implements GrabListener, DirectGame 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) { + try { + SDLActivity.handleKeyEvent(this, eventKeycode, event, null); + return true; + } catch (Throwable ignored){ + Log.e(TAG, "SDL failed to send keyevent!"); + } + } + if(!sdlEnabled && isGamepadEvent){ if(mGamepadHandler == null) createGamepad(this, event.getDevice()); mInputManager.handleKeyEventInput(getContext(), event, mGamepadHandler); 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 ae9d5421e..e8a95e88e 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 androidx.annotation.Nullable; 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 class PojavProfile { } 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 24bd00d33..288bbe733 100644 --- a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/Tools.java @@ -3,6 +3,7 @@ package net.kdt.pojavlaunch; import static android.os.Build.VERSION.SDK_INT; import static android.os.Build.VERSION_CODES.P; 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; @@ -21,10 +22,13 @@ import android.content.res.Resources; 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; import android.os.Environment; +import android.os.FileObserver; import android.os.Handler; import android.os.Looper; import android.provider.DocumentsContract; @@ -32,6 +36,7 @@ import android.provider.OpenableColumns; 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; @@ -74,6 +79,7 @@ import net.kdt.pojavlaunch.value.launcherprofiles.MinecraftProfile; 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; @@ -87,6 +93,7 @@ import java.io.PrintWriter; import java.io.StringWriter; import java.lang.ref.WeakReference; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.net.URLConnection; import java.nio.charset.StandardCharsets; import java.text.ParseException; @@ -224,6 +231,27 @@ public final class Tools { return false; } + /** + * Search for TouchController mod to automatically enable TouchController mod support. + * + * @param gameDir current game directory + * @return whether TouchController is found + */ + 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().toLowerCase(Locale.ROOT); + if (name.contains("touchcontroller")) { + return true; + } + } + return false; + } + /** * Initialize OpenGL and do checks to see if the GPU of the device is affected by the render * distance issue. @@ -286,6 +314,8 @@ public final class Tools { } LauncherProfiles.load(); File gamedir = Tools.getGameDirPath(minecraftProfile); + startControllableMitigation(activity, gamedir); + startOldLegacy4JMitigation(activity, gamedir); if(checkRenderDistance(gamedir)) { LifecycleAwareAlertDialog.DialogCreator dialogCreator = ((alertDialog, dialogBuilder) -> dialogBuilder.setMessage(activity.getString(R.string.ltw_render_distance_warning_msg)) @@ -338,7 +368,11 @@ public final class Tools { javaArgList.addAll(Arrays.asList(getMinecraftJVMArgs(versionId, gamedir))); javaArgList.add("-cp"); - javaArgList.add(launchClassPath + ":" + getLWJGL3ClassPath()); + if (launchClassPath.contains("bta-client-")){ // BTADownloadTask.BASE_JSON sets this. Jank. + // BTA for some reason needs this to be last or else it uses the wrong lwjgl + javaArgList.add(launchClassPath + ":" + getLWJGL3ClassPath()); + // Legacy Fabric needs this to be first or else it uses the wrong lwjgl + } else javaArgList.add(getLWJGL3ClassPath() + ":" + launchClassPath); javaArgList.add(versionInfo.mainClass); javaArgList.addAll(Arrays.asList(launchArgs)); @@ -350,6 +384,109 @@ public final class Tools { // 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){ @@ -798,6 +935,9 @@ public final class Tools { 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) @@ -899,7 +1039,7 @@ public final class Tools { 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 @@ -1429,6 +1569,18 @@ public final class Tools { 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){ MinecraftAccount currentProfile = PojavProfile.getCurrentProfileContent(ctx, null); return currentProfile != null && currentProfile.isDemo(); @@ -1438,4 +1590,117 @@ public final class Tools { MinecraftAccount currentProfile = PojavProfile.getCurrentProfileContent(ctx, null); return currentProfile == null || currentProfile.isLocal(); } + public static boolean hasOnlineProfile(){ + for (MinecraftAccount accountToCheck : getAllProfiles()) { + if (!accountToCheck.isLocal() && !accountToCheck.isDemo()) { + return true; + } + } + return false; + } + + 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/customcontrols/buttons/ControlButton.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/buttons/ControlButton.java index 5fb9d687b..efa07b234 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.MotionEvent; 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 class ControlButton extends TextView implements ControlInterface { 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/Gamepad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/gamepad/Gamepad.java index fe97ec601..e810273e7 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 38f6dd4ca..418c46bb2 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,13 +1,20 @@ 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.util.Log; 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; @@ -15,7 +22,7 @@ import net.kdt.pojavlaunch.prefs.LauncherPreferences; import org.lwjgl.glfw.CallbackBridge; @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 +39,43 @@ public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChan 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(); @@ -128,7 +164,11 @@ public class AndroidPointerCapture implements ViewTreeObserver.OnWindowFocusChan @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/Touchpad.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/customcontrols/mouse/Touchpad.java index 7a1f71d86..78ca9e710 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 class Touchpad extends View implements GrabListener, AbstractTouchpad { 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/fragments/LocalLoginFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/LocalLoginFragment.java index 86a00e375..e4e2e5d4f 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 class LocalLoginFragment extends Fragment { @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 364c3d0b1..ee59d87e4 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; @@ -53,11 +55,13 @@ public class MainMenuFragment extends Fragment { 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; - }); + if (hasOnlineProfile()) { + mInstallJarButton.setOnClickListener(v -> runInstallerWithConfirmation(false)); + mInstallJarButton.setOnLongClickListener(v -> { + runInstallerWithConfirmation(true); + return true; + }); + } else mInstallJarButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity())); mEditProfileButton.setOnClickListener(v -> mVersionSpinner.openProfileEditor(requireActivity())); mPlayButton.setOnClickListener(v -> ExtraCore.setValue(ExtraConstants.LAUNCH_GAME, true)); @@ -65,13 +69,12 @@ public class MainMenuFragment extends Fragment { mShareLogsButton.setOnClickListener((v) -> shareLog(requireContext())); mOpenDirectoryButton.setOnClickListener((v)-> { - Tools.switchDemo(Tools.isDemoProfile(v.getContext())); // avoid switching accounts being able to access - if(Tools.isDemoProfile(v.getContext())){ - Toast.makeText(v.getContext(), R.string.toast_not_available_demo, Toast.LENGTH_LONG).show(); - return; - } + if (Tools.isDemoProfile(v.getContext())){ // Say a different message when on demo profile since they might see the hidden demo folder + hasNoOnlineProfileDialog(getActivity(), getString(R.string.demo_unsupported), getString(R.string.change_account)); + } else if (!hasOnlineProfile()) { // Otherwise display the generic pop-up to log in + hasNoOnlineProfileDialog(requireActivity()); + } else openPath(v.getContext(), getCurrentProfileDirectory(), false); - openPath(v.getContext(), getCurrentProfileDirectory(), false); }); @@ -97,12 +100,6 @@ public class MainMenuFragment extends Fragment { } private void runInstallerWithConfirmation(boolean isCustomArgs) { - // avoid using custom installers to install a version - if(Tools.isLocalProfile(requireContext()) || Tools.isDemoProfile(requireContext())){ - Toast.makeText(requireContext(), R.string.toast_not_available_demo, Toast.LENGTH_LONG).show(); - return; - } - if (ProgressKeeper.getTaskCount() == 0) Tools.installMod(requireActivity(), isCustomArgs); else 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 1260cdde6..7b2952d3b 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 androidx.fragment.app.Fragment; 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 abstract class ModVersionListFragment extends Fragment implements Runn 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 abstract class ModVersionListFragment extends Fragment implements Runn 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 abstract class ModVersionListFragment extends Fragment implements Runn 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 abstract class ModVersionListFragment extends Fragment implements Runn 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/NeoForgeInstallFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java new file mode 100644 index 000000000..b901da6e8 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/fragments/NeoForgeInstallFragment.java @@ -0,0 +1,80 @@ +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 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.ModloaderListenerProxy; +import net.kdt.pojavlaunch.modloaders.NeoForgeDownloadTask; +import net.kdt.pojavlaunch.modloaders.NeoForgeVersionListAdapter; +import net.kdt.pojavlaunch.utils.DownloadUtils; + +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.List; + +public class NeoForgeInstallFragment extends ModVersionListFragment> { + public static final String TAG = "NeoForgeInstallFragment"; + public NeoForgeInstallFragment() { + super(TAG); + } + + private static final String NEOFORGE_METADATA_URL = "https://meta.prismlauncher.org/v1/net.neoforged/index.json"; + + + @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() { + String test = null; + try { + test = DownloadUtils.downloadStringCached(NEOFORGE_METADATA_URL, "neoforge_versions", input -> input); + } catch (Exception e) { + Tools.showErrorRemote(e); + } + return Collections.singletonList(test); + // Moved the parsing logic to the adapter because there is no way to get this info easily, we use prism's index + // since neoforge doesn't actually give this information easily anywhere. + // To clarify, neoforge does not provide maven APIs to get supported Minecraft versions for each loader version + + } + + @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"); + context.startActivity(modInstallerStartIntent); + } +} 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 bd8575069..9acb9d15a 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,5 +1,8 @@ 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; @@ -33,6 +36,8 @@ public class ProfileTypeSelectFragment extends Fragment { tryInstall(FabricInstallFragment.class, FabricInstallFragment.TAG)); view.findViewById(R.id.modded_profile_forge).setOnClickListener((v)-> 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)-> tryInstall(SearchModFragment.class, SearchModFragment.TAG)); view.findViewById(R.id.modded_profile_quilt).setOnClickListener((v)-> @@ -42,8 +47,8 @@ public class ProfileTypeSelectFragment extends Fragment { } private void tryInstall(Class fragmentClass, String tag){ - if(Tools.isLocalProfile(requireContext()) || Tools.isDemoProfile(requireContext())){ - Toast.makeText(requireContext(), R.string.toast_not_available_demo, Toast.LENGTH_LONG).show(); + if(!hasOnlineProfile()){ + hasNoOnlineProfileDialog(requireActivity()); } else { Tools.swapFragment(requireActivity(), fragmentClass, tag, null); } 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 781408988..516620698 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; @@ -24,6 +26,6 @@ public class SelectAuthFragment extends Fragment { 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)); + mLocalButton.setOnClickListener(v -> hasNoOnlineProfileDialog(requireActivity(), () -> Tools.swapFragment(requireActivity(), LocalLoginFragment.class, LocalLoginFragment.TAG, null))); } } 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 2cced7b7e..566f87a6c 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 class DownloadMirror { * @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/NeoForgeDownloadTask.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java new file mode 100644 index 000000000..9cb40e683 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeDownloadTask.java @@ -0,0 +1,88 @@ +package net.kdt.pojavlaunch.modloaders; + +import com.kdt.mcgui.ProgressLayout; + +import net.kdt.pojavlaunch.R; +import net.kdt.pojavlaunch.Tools; +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 String mDownloadUrl; + private String mFullVersion; + private String mLoaderVersion; + private String mGameVersion; + private final ModloaderDownloadListener mListener; + public NeoForgeDownloadTask(ModloaderDownloadListener listener, String forgeVersion) { + this.mListener = listener; + this.mDownloadUrl = "https://maven.neoforged.net/releases/net/neoforged/neoforge/"+ forgeVersion +"/neoforge-"+forgeVersion+"-installer.jar"; + this.mFullVersion = forgeVersion; + } + + public NeoForgeDownloadTask(ModloaderDownloadListener listener, String gameVersion, String loaderVersion) { + this.mListener = listener; + this.mLoaderVersion = loaderVersion; + this.mGameVersion = gameVersion; + } + @Override + public void run() { + if(determineDownloadUrl()) { + downloadForge(); + } + ProgressLayout.clearProgress(ProgressLayout.INSTALL_MODPACK); + } + + @Override + public void updateProgress(int curr, int max) { + int progress100 = (int)(((float)curr / (float)max)*100f); + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, progress100, R.string.forge_dl_progress, mFullVersion); + } + + private void downloadForge() { + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_dl_progress, mFullVersion); + try { + File destinationFile = new File(Tools.DIR_CACHE, "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() { + if(mDownloadUrl != null && mFullVersion != null) return true; + ProgressKeeper.submitProgress(ProgressLayout.INSTALL_MODPACK, 0, R.string.forge_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 forgeVersions = ForgeUtils.downloadForgeVersions(); + if(forgeVersions == null) return false; + String versionStart = mGameVersion+"-"+mLoaderVersion; + for(String versionName : forgeVersions) { + if(!versionName.startsWith(versionStart)) continue; + mFullVersion = versionName; + mDownloadUrl = ForgeUtils.getInstallerUrl(mFullVersion); + return true; + } + return false; + } + +} diff --git a/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java new file mode 100644 index 000000000..e02670329 --- /dev/null +++ b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/modloaders/NeoForgeVersionListAdapter.java @@ -0,0 +1,119 @@ +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 com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; + +public class NeoForgeVersionListAdapter extends BaseExpandableListAdapter implements ExpandableListAdapter { + private final LayoutInflater mLayoutInflater; + private final LinkedHashMap> minecraftToLoaderVersionsHashmap; + private LinkedHashSet generatedHashSet = null; + + + public NeoForgeVersionListAdapter(List forgeVersions, LayoutInflater layoutInflater) { + this.mLayoutInflater = layoutInflater; + minecraftToLoaderVersionsHashmap = new LinkedHashMap<>(); + JsonArray versionsJsonArray = JsonParser.parseString(forgeVersions.get(0)).getAsJsonObject().getAsJsonArray("versions"); + + ArrayList sortedVersionsList = new ArrayList<>(); + for (JsonElement elem : versionsJsonArray) { + sortedVersionsList.add(elem); + } + Collections.sort(sortedVersionsList, (o1, o2) -> { + String versionString1 = ((JsonObject) o1).get("requires").getAsJsonArray().get(0).getAsJsonObject().get("equals").getAsString(); + String versionString2 = ((JsonObject) o2).get("requires").getAsJsonArray().get(0).getAsJsonObject().get("equals").getAsString(); + return versionString2.compareTo(versionString1); // Sorts by Minecraft version + }); + + for (JsonElement sortedVersionPick : sortedVersionsList) { + String loaderVersion = ((JsonObject) sortedVersionPick).get("version").getAsString(); + String minecraftVersion = ((JsonObject) sortedVersionPick).get("requires").getAsJsonArray().get(0).getAsJsonObject().get("equals").getAsString(); + if (minecraftToLoaderVersionsHashmap.containsKey(minecraftVersion)) { + minecraftToLoaderVersionsHashmap.get(minecraftVersion).add(loaderVersion); + } else { + generatedHashSet = new LinkedHashSet<>(); + generatedHashSet.add(loaderVersion); + minecraftToLoaderVersionsHashmap.put(minecraftVersion, generatedHashSet); + } + } + } + @Override + public int getGroupCount() { + return minecraftToLoaderVersionsHashmap.size(); + } + + @Override + public int getChildrenCount(int i) { + return new ArrayList<>(minecraftToLoaderVersionsHashmap.values()).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 minecraftToLoaderVersionsHashmap.keySet().toArray()[i].toString(); + } + + private String getForgeVersion(int i, int i1){ + return new ArrayList<>(minecraftToLoaderVersionsHashmap.values()).get(i).toArray()[i1].toString(); + } + + @Override + public boolean isChildSelectable(int i, int i1) { + return true; + } +} 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 d6f168f24..1fe730977 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 @@ -27,7 +27,6 @@ public class LauncherPreferences { 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; @@ -40,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; @@ -70,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) { @@ -77,7 +82,6 @@ public class LauncherPreferences { 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; @@ -87,6 +91,8 @@ public class LauncherPreferences { 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", ""); @@ -112,6 +118,9 @@ public class LauncherPreferences { 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)) { @@ -231,12 +240,16 @@ public class LauncherPreferences { // 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; MGConfigJson.put("enableExtGL43", gl43exts); MGConfigJson.put("enableExtComputeShader", computeShaderext); - - MGConfigJson.put("enableCompatibleMode", Integer.parseInt(DEFAULT_PREF.getString("", "0"))); // Placeholder, doesn't do anything on current MG - 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", "2048"))); + MGConfigJson.put("angleDepthClearFixMode", angleDepthClearFixMode); + MGConfigJson.put("enableExtTimerQuery", timerQueryExt); + 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 { 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 5458dbf57..0689a7ce1 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_INVERT_Y; 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 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { 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 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { 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 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { mGyroXSwitch.setChecked(mOriginalGyroXEnabled); mGyroYSwitch.setChecked(mOriginalGyroYEnabled); mGestureSwitch.setChecked(mOriginalGestureDisabled); + mMouseGrabSwitch.setChecked(mOriginalMouseGrab); mGyroSwitch.setOnCheckedChangeListener((buttonView, isChecked) -> { PREF_ENABLE_GYRO = isChecked; @@ -122,6 +126,11 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { 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 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { setSeekTextPercent(mResolutionText, mResolutionBar.getProgress()); + updateMouseGrabVisibility(); updateGyroVisibility(mOriginalGyroEnabled); updateGestureVisibility(mOriginalGestureDisabled); } @@ -172,6 +182,10 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { 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 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { mGyroXSwitch.setOnCheckedChangeListener(null); mGyroYSwitch.setOnCheckedChangeListener(null); mGestureSwitch.setOnCheckedChangeListener(null); + mMouseGrabSwitch.setOnCheckedChangeListener(null); mGyroSensitivityBar.setOnSeekBarChangeListener(null); mMouseSpeedBar.setOnSeekBarChangeListener(null); @@ -225,6 +240,7 @@ public abstract class QuickSettingSideDialog extends com.kdt.SideDialogView { 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 7002f8452..bc793cba0 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 @@ import net.kdt.pojavlaunch.prefs.LauncherPreferences; public class LauncherPreferenceControlFragment extends LauncherPreferenceFragment { private boolean mGyroAvailable = false; + @Override public void onCreatePreferences(Bundle b, String str) { // Get values @@ -20,6 +21,7 @@ public class LauncherPreferenceControlFragment extends LauncherPreferenceFragmen 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 class LauncherPreferenceControlFragment extends LauncherPreferenceFragmen 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 class LauncherPreferenceControlFragment extends LauncherPreferenceFragmen 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 class LauncherPreferenceControlFragment extends LauncherPreferenceFragmen 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/LauncherPreferenceFragment.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/prefs/screens/LauncherPreferenceFragment.java index 7275f8691..d4791a6f1 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 @@ -35,6 +35,7 @@ public class LauncherPreferenceFragment extends PreferenceFragmentCompat impleme 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 @@ public class LauncherPreferenceFragment extends PreferenceFragmentCompat impleme 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); } 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 index 57918b5a1..63a25e543 100644 --- 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 @@ -7,10 +7,10 @@ import android.os.Bundle; import android.text.Editable; import android.text.TextWatcher; -import androidx.annotation.NonNull; import androidx.preference.EditTextPreference; import androidx.preference.ListPreference; import androidx.preference.Preference; +import androidx.preference.SwitchPreference; import net.kdt.pojavlaunch.R; @@ -18,10 +18,16 @@ 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() { @@ -29,18 +35,20 @@ public class LauncherPreferenceRendererSettingsFragment extends LauncherPreferen 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()){ + if (editText.getText().toString().isEmpty()) { editText.setText("0"); } - if (Long.parseLong(editText.getText().toString()) > Integer.MAX_VALUE){ + if (Long.parseLong(editText.getText().toString()) > Integer.MAX_VALUE) { editText.setError("Too big! Setting to maximum value"); editText.setText(String.valueOf(Integer.MAX_VALUE)); } @@ -49,12 +57,30 @@ public class LauncherPreferenceRendererSettingsFragment extends LauncherPreferen }); }); 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() { @@ -62,6 +88,8 @@ public class LauncherPreferenceRendererSettingsFragment extends LauncherPreferen 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(); } + } 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 14f412e51..c17bfd417 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 class LauncherPreferenceVideoFragment extends LauncherPreferenceFragment 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/tasks/AsyncVersionList.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/tasks/AsyncVersionList.java index 0a18ac0fb..3238a4ffd 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 @@ public class AsyncVersionList { // 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 70a4f0834..40abf1723 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 @@ package net.kdt.pojavlaunch.tasks; 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,9 +28,12 @@ import net.kdt.pojavlaunch.value.DependentLibrary; 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.net.UnknownHostException; import java.util.ArrayList; import java.util.Map; import java.util.Set; @@ -56,6 +62,7 @@ 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. @@ -65,11 +72,13 @@ 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); @@ -77,14 +86,32 @@ public class MinecraftDownloader { sExecutorService.execute(() -> { try { - if(isLocalProfile){ - throw new RuntimeException("Download failed. Please make sure you are logged in with a Microsoft Account."); - } + 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 (UnknownHostException e){ - Log.i("DownloadMirror", e.toString()); - Tools.showErrorRemote("Can't download Minecraft, no internet connection found", e); + } }catch (Exception e) { listener.onDownloadFailed(e); } @@ -473,18 +500,49 @@ public class MinecraftDownloader { * 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/utils/DownloadUtils.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/utils/DownloadUtils.java index 0c4d35eed..0f3bc3e96 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 @@ -61,6 +61,12 @@ public class DownloadUtils { 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; + } } } 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 22d046c5e..41fb5829e 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 @@ -30,6 +30,7 @@ import net.kdt.pojavlaunch.multirt.MultiRTUtils; import net.kdt.pojavlaunch.multirt.Runtime; import net.kdt.pojavlaunch.plugins.FFmpegPlugin; import net.kdt.pojavlaunch.prefs.*; + import org.lwjgl.glfw.*; public class JREUtils { 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 000000000..c999a283e --- /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 = "Amethyst"; + + 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/value/MinecraftAccount.java b/app_pojavlauncher/src/main/java/net/kdt/pojavlaunch/value/MinecraftAccount.java index 4c524da5d..daf857dee 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.graphics.Bitmap; import android.util.Base64; import androidx.annotation.Keep; +import androidx.annotation.Nullable; import org.apache.commons.io.IOUtils; @@ -68,7 +69,7 @@ public class MinecraftAccount { 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 { @@ -92,7 +93,7 @@ public class MinecraftAccount { 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/org/libsdl/app/HIDDevice.java b/app_pojavlauncher/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 000000000..988f348db --- /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 000000000..f61518ae3 --- /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 000000000..27ef9c3e0 --- /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 000000000..b5de68b68 --- /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 000000000..ffd1e7896 --- /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 000000000..a6096a0cc --- /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