package org.libsdl.app; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.List; import java.lang.reflect.Method; import android.app.*; import android.content.*; import android.view.*; import android.view.inputmethod.BaseInputConnection; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputMethodManager; import android.widget.AbsoluteLayout; import android.widget.Button; import android.widget.LinearLayout; import android.widget.TextView; import android.os.*; import android.util.Log; import android.util.SparseArray; import android.graphics.*; import android.graphics.drawable.Drawable; import android.media.*; import android.hardware.*; import android.content.pm.ActivityInfo; /** SDL Activity */ public class SDLActivity extends Activity { private static final String TAG = "SDL"; // Keep track of the paused state public static boolean mIsPaused, mIsSurfaceReady, mHasFocus; public static boolean mExitCalledFromJava; /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ public static boolean mBrokenLibraries; // If we want to separate mouse and touch events. // This is only toggled in native code when a hint is set! public static boolean mSeparateMouseAndTouch; // Main components protected static SDLActivity mSingleton; protected static SDLSurface mSurface; protected static View mTextEdit; protected static ViewGroup mLayout; protected static SDLJoystickHandler mJoystickHandler; // This is what SDL runs in. It invokes SDL_main(), eventually protected static Thread mSDLThread; // Audio protected static AudioTrack mAudioTrack; /** * 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 "SDL2". * Also keep in mind that the order the libraries are loaded may matter. * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). */ protected String[] getLibraries() { return new String[] { "SDL2", // "SDL2_image", // "SDL2_mixer", // "SDL2_net", // "SDL2_ttf", "main" }; } // Load the .so public void loadLibraries() { for (String lib : getLibraries()) { System.loadLibrary(lib); } } /** * 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; mJoystickHandler = null; mSDLThread = null; mAudioTrack = null; mExitCalledFromJava = false; mBrokenLibraries = false; mIsPaused = false; mIsSurfaceReady = false; mHasFocus = true; } // Setup @Override protected void onCreate(Bundle savedInstanceState) { Log.v(TAG, "Device: " + android.os.Build.DEVICE); Log.v(TAG, "Model: " + android.os.Build.MODEL); Log.v(TAG, "onCreate(): " + mSingleton); super.onCreate(savedInstanceState); SDLActivity.initialize(); // So we can call stuff from static callbacks mSingleton = this; // Load shared libraries String errorMsgBrokenLib = ""; try { loadLibraries(); } 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) { 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; } // Set up the surface mSurface = new SDLSurface(getApplication()); if(Build.VERSION.SDK_INT >= 12) { mJoystickHandler = new SDLJoystickHandler_API12(); } else { mJoystickHandler = new SDLJoystickHandler(); } mLayout = new AbsoluteLayout(this); mLayout.addView(mSurface); setContentView(mLayout); // 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); } } } // Events @Override protected void onPause() { Log.v(TAG, "onPause()"); super.onPause(); if (SDLActivity.mBrokenLibraries) { return; } SDLActivity.handlePause(); } @Override protected void onResume() { Log.v(TAG, "onResume()"); super.onResume(); if (SDLActivity.mBrokenLibraries) { return; } SDLActivity.handleResume(); } @Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); if (SDLActivity.mBrokenLibraries) { return; } SDLActivity.mHasFocus = hasFocus; if (hasFocus) { SDLActivity.handleResume(); } } @Override public void onLowMemory() { Log.v(TAG, "onLowMemory()"); super.onLowMemory(); if (SDLActivity.mBrokenLibraries) { return; } SDLActivity.nativeLowMemory(); } @Override protected void onDestroy() { Log.v(TAG, "onDestroy()"); if (SDLActivity.mBrokenLibraries) { super.onDestroy(); // Reset everything in case the user re opens the app SDLActivity.initialize(); return; } // Send a quit message to the application SDLActivity.mExitCalledFromJava = true; SDLActivity.nativeQuit(); // Now wait for the SDL thread to quit if (SDLActivity.mSDLThread != null) { try { SDLActivity.mSDLThread.join(); } catch(Exception e) { Log.v(TAG, "Problem stopping thread: " + e); } SDLActivity.mSDLThread = null; //Log.v(TAG, "Finished waiting for SDL thread"); } super.onDestroy(); // Reset everything in case the user re opens the app SDLActivity.initialize(); } @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 == 168 || /* API 11: KeyEvent.KEYCODE_ZOOM_IN */ keyCode == 169 /* API 11: KeyEvent.KEYCODE_ZOOM_OUT */ ) { return false; } return super.dispatchKeyEvent(event); } /** Called by onPause or surfaceDestroyed. Even if surfaceDestroyed * is the first to be called, mIsSurfaceReady should still be set * to 'true' during the call to onPause (in a usual scenario). */ public static void handlePause() { if (!SDLActivity.mIsPaused && SDLActivity.mIsSurfaceReady) { SDLActivity.mIsPaused = true; SDLActivity.nativePause(); mSurface.handlePause(); } } /** Called by onResume or surfaceCreated. An actual resume should be done only when the surface is ready. * Note: Some Android variants may send multiple surfaceChanged events, so we don't need to resume * every time we get one of those events, only if it comes after surfaceDestroyed */ public static void handleResume() { if (SDLActivity.mIsPaused && SDLActivity.mIsSurfaceReady && SDLActivity.mHasFocus) { SDLActivity.mIsPaused = false; SDLActivity.nativeResume(); mSurface.handleResume(); } } /* The native thread has finished */ public static void handleNativeExit() { SDLActivity.mSDLThread = null; mSingleton.finish(); } // Messages from the SDLMain thread static final int COMMAND_CHANGE_TITLE = 1; static final int COMMAND_UNUSED = 2; static final int COMMAND_TEXTEDIT_HIDE = 3; static final int COMMAND_SET_KEEP_SCREEN_ON = 5; protected static final int COMMAND_USER = 0x8000; /** * 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 = 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_TEXTEDIT_HIDE: if (mTextEdit != null) { mTextEdit.setVisibility(View.GONE); InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); } break; case COMMAND_SET_KEEP_SCREEN_ON: { Window window = ((Activity) context).getWindow(); if (window != null) { if ((msg.obj instanceof Integer) && (((Integer) msg.obj).intValue() != 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 boolean sendCommand(int command, Object data) { Message msg = commandHandler.obtainMessage(); msg.arg1 = command; msg.obj = data; return commandHandler.sendMessage(msg); } // C functions we call public static native int nativeInit(Object arguments); public static native void nativeLowMemory(); public static native void nativeQuit(); public static native void nativePause(); public static native void nativeResume(); public static native void onNativeDropFile(String filename); public static native void onNativeResize(int x, int y, int format, float rate); public static native int onNativePadDown(int device_id, int keycode); public static native int onNativePadUp(int device_id, int keycode); public static native void onNativeJoy(int device_id, int axis, float value); public static native void onNativeHat(int device_id, int hat_id, int x, int y); public static native void onNativeKeyDown(int keycode); public static native void onNativeKeyUp(int keycode); public static native void onNativeKeyboardFocusLost(); public static native void onNativeMouse(int button, int action, float x, float y); public static native void onNativeTouch(int touchDevId, int pointerFingerId, int action, float x, float y, float p); public static native void onNativeAccel(float x, float y, float z); public static native void onNativeSurfaceChanged(); public static native void onNativeSurfaceDestroyed(); public static native int nativeAddJoystick(int device_id, String name, int is_accelerometer, int nbuttons, int naxes, int nhats, int nballs); public static native int nativeRemoveJoystick(int device_id); public static native String nativeGetHint(String name); /** * 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 boolean sendMessage(int command, int param) { return mSingleton.sendCommand(command, Integer.valueOf(param)); } /** * This method is called by SDL using JNI. */ public static Context getContext() { return mSingleton; } /** * This method is called by SDL using JNI. * @return result of getSystemService(name) but executed on UI thread. */ public Object getSystemServiceFromUiThread(final String name) { final Object lock = new Object(); final Object[] results = new Object[2]; // array for writable variables synchronized (lock) { runOnUiThread(new Runnable() { @Override public void run() { synchronized (lock) { results[0] = getSystemService(name); results[1] = Boolean.TRUE; lock.notify(); } } }); if (results[1] == null) { try { lock.wait(); } catch (InterruptedException ex) { ex.printStackTrace(); } } } return results[0]; } 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 x, y, w, h; public ShowTextInputTask(int x, int y, int w, int h) { this.x = x; this.y = y; this.w = w; this.h = h; } @Override public void run() { AbsoluteLayout.LayoutParams params = new AbsoluteLayout.LayoutParams( w, h + HEIGHT_PADDING, x, y); if (mTextEdit == null) { mTextEdit = new DummyEdit(getContext()); mLayout.addView(mTextEdit, params); } else { mTextEdit.setLayoutParams(params); } mTextEdit.setVisibility(View.VISIBLE); mTextEdit.requestFocus(); InputMethodManager imm = (InputMethodManager) getContext().getSystemService(Context.INPUT_METHOD_SERVICE); imm.showSoftInput(mTextEdit, 0); } } /** * This method is called by SDL using JNI. */ public static boolean showTextInput(int x, int y, int w, int h) { // Transfer the task to the main thread as a Runnable return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); } /** * This method is called by SDL using JNI. */ public static Surface getNativeSurface() { return SDLActivity.mSurface.getNativeSurface(); } // Audio /** * This method is called by SDL using JNI. */ public static int audioInit(int sampleRate, boolean is16Bit, boolean isStereo, int desiredFrames) { int channelConfig = isStereo ? AudioFormat.CHANNEL_CONFIGURATION_STEREO : AudioFormat.CHANNEL_CONFIGURATION_MONO; int audioFormat = is16Bit ? AudioFormat.ENCODING_PCM_16BIT : AudioFormat.ENCODING_PCM_8BIT; int frameSize = (isStereo ? 2 : 1) * (is16Bit ? 2 : 1); Log.v(TAG, "SDL audio: wanted " + (isStereo ? "stereo" : "mono") + " " + (is16Bit ? "16-bit" : "8-bit") + " " + (sampleRate / 1000f) + "kHz, " + desiredFrames + " frames buffer"); // Let the user pick a larger buffer if they really want -- but ye // gods they probably shouldn't, the minimums are horrifyingly high // latency already desiredFrames = Math.max(desiredFrames, (AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat) + frameSize - 1) / frameSize); if (mAudioTrack == null) { mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM); // Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid // Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java // Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState() if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) { Log.e(TAG, "Failed during initialization of Audio Track"); mAudioTrack = null; return -1; } mAudioTrack.play(); } Log.v(TAG, "SDL audio: got " + ((mAudioTrack.getChannelCount() >= 2) ? "stereo" : "mono") + " " + ((mAudioTrack.getAudioFormat() == AudioFormat.ENCODING_PCM_16BIT) ? "16-bit" : "8-bit") + " " + (mAudioTrack.getSampleRate() / 1000f) + "kHz, " + desiredFrames + " frames buffer"); return 0; } /** * This method is called by SDL using JNI. */ public static void audioWriteShortBuffer(short[] buffer) { for (int i = 0; i < buffer.length; ) { int result = mAudioTrack.write(buffer, i, buffer.length - i); if (result > 0) { i += result; } else if (result == 0) { try { Thread.sleep(1); } catch(InterruptedException e) { // Nom nom } } else { Log.w(TAG, "SDL audio: error return from write(short)"); return; } } } /** * This method is called by SDL using JNI. */ public static void audioWriteByteBuffer(byte[] buffer) { for (int i = 0; i < buffer.length; ) { int result = mAudioTrack.write(buffer, i, buffer.length - i); if (result > 0) { i += result; } else if (result == 0) { try { Thread.sleep(1); } catch(InterruptedException e) { // Nom nom } } else { Log.w(TAG, "SDL audio: error return from write(byte)"); return; } } } /** * This method is called by SDL using JNI. */ public static void audioQuit() { if (mAudioTrack != null) { mAudioTrack.stop(); mAudioTrack = null; } } // Input /** * This method is called by SDL using JNI. * @return an array which may be empty but is never null. */ public static int[] inputGetInputDeviceIds(int sources) { int[] ids = InputDevice.getDeviceIds(); int[] filtered = new int[ids.length]; int used = 0; for (int i = 0; i < ids.length; ++i) { InputDevice device = InputDevice.getDevice(ids[i]); if ((device != null) && ((device.getSources() & sources) != 0)) { filtered[used++] = device.getId(); } } return Arrays.copyOf(filtered, used); } // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance public static boolean handleJoystickMotionEvent(MotionEvent event) { return mJoystickHandler.handleMotionEvent(event); } /** * This method is called by SDL using JNI. */ public static void pollInputDevices() { if (SDLActivity.mSDLThread != null) { mJoystickHandler.pollInputDevices(); } } // APK expansion files support /** com.android.vending.expansion.zipfile.ZipResourceFile object or null. */ private Object expansionFile; /** com.android.vending.expansion.zipfile.ZipResourceFile's getInputStream() or null. */ private Method expansionFileMethod; /** * This method was called by SDL using JNI. * @deprecated because of an incorrect name */ @Deprecated public InputStream openAPKExtensionInputStream(String fileName) throws IOException { return openAPKExpansionInputStream(fileName); } /** * This method is called by SDL using JNI. * @return an InputStream on success or null if no expansion file was used. * @throws IOException on errors. Message is set for the SDL error message. */ public InputStream openAPKExpansionInputStream(String fileName) throws IOException { // Get a ZipResourceFile representing a merger of both the main and patch files if (expansionFile == null) { String mainHint = nativeGetHint("SDL_ANDROID_APK_EXPANSION_MAIN_FILE_VERSION"); if (mainHint == null) { return null; // no expansion use if no main version was set } String patchHint = nativeGetHint("SDL_ANDROID_APK_EXPANSION_PATCH_FILE_VERSION"); if (patchHint == null) { return null; // no expansion use if no patch version was set } Integer mainVersion; Integer patchVersion; try { mainVersion = Integer.valueOf(mainHint); patchVersion = Integer.valueOf(patchHint); } catch (NumberFormatException ex) { ex.printStackTrace(); throw new IOException("No valid file versions set for APK expansion files", ex); } try { // To avoid direct dependency on Google APK expansion library that is // not a part of Android SDK we access it using reflection expansionFile = Class.forName("com.android.vending.expansion.zipfile.APKExpansionSupport") .getMethod("getAPKExpansionZipFile", Context.class, int.class, int.class) .invoke(null, this, mainVersion, patchVersion); expansionFileMethod = expansionFile.getClass() .getMethod("getInputStream", String.class); } catch (Exception ex) { ex.printStackTrace(); expansionFile = null; expansionFileMethod = null; throw new IOException("Could not access APK expansion support library", ex); } } // Get an input stream for a known file inside the expansion file ZIPs InputStream fileStream; try { fileStream = (InputStream)expansionFileMethod.invoke(expansionFile, fileName); } catch (Exception ex) { // calling "getInputStream" failed ex.printStackTrace(); throw new IOException("Could not open stream from APK expansion file", ex); } if (fileStream == null) { // calling "getInputStream" was successful but null was returned throw new IOException("Could not find path in APK expansion file"); } return fileStream; } // Messagebox /** Result of current messagebox. Also used for blocking the calling thread. */ protected final int[] messageboxSelection = new int[1]; /** Id of current dialog. */ protected int dialogs = 0; /** * 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() { showDialog(dialogs++, args); } }); // block the calling thread synchronized (messageboxSelection) { try { messageboxSelection.wait(); } catch (InterruptedException ex) { ex.printStackTrace(); return -1; } } // return selected value return messageboxSelection[0]; } @Override protected Dialog onCreateDialog(int ignore, 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 Dialog dialog = new Dialog(this); 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