summary refs log tree commit diff
path: root/android/app/src/main/java/com/classicube/MainActivity.java
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/main/java/com/classicube/MainActivity.java')
-rw-r--r--android/app/src/main/java/com/classicube/MainActivity.java1012
1 files changed, 1012 insertions, 0 deletions
diff --git a/android/app/src/main/java/com/classicube/MainActivity.java b/android/app/src/main/java/com/classicube/MainActivity.java
new file mode 100644
index 0000000..803963b
--- /dev/null
+++ b/android/app/src/main/java/com/classicube/MainActivity.java
@@ -0,0 +1,1012 @@
+package com.classicube;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.lang.reflect.Method;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.ConcurrentLinkedQueue;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.content.Context;
+import android.content.ClipboardManager;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ApplicationInfo;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.graphics.PixelFormat;
+import android.net.Uri;
+import android.os.Bundle;
+import android.provider.OpenableColumns;
+import android.provider.Settings.Secure;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.WindowManager;
+import android.view.View;
+import android.view.Window;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+
+// This class contains all the glue/interop code for bridging ClassiCube to the java Android world.
+// Some functionality is only available on later Android versions - try {} catch {} is used in such places 
+//   to ensure that the game can still run on earlier Android versions (albeit with reduced functionality)
+// Currently the minimum required API level to run the game is level 9 (Android 2.3). 
+// When using Android functionality, always aim to add a comment with the API level that the functionality 
+//   was added in, as this will make things easier if the minimum required API level is ever changed again
+
+// implements InputQueue.Callback
+public class MainActivity extends Activity 
+{
+	public boolean launcher;
+	// ==================================================================
+	// ---------------------------- COMMANDS ----------------------------
+	// ==================================================================
+	//  The main thread (which receives events) is separate from the game thread (which processes events)
+	//  Therefore pushing/pulling events must be thread-safe, which is achieved through ConcurrentLinkedQueue
+	//  Additionally, a cache is used (freeCmds) to avoid constantly allocating NativeCmdArgs instances
+	class NativeCmdArgs { public int cmd, arg1, arg2, arg3, arg4; public String str; public Surface sur; }
+	// static to persist across activity destroy/create
+	static Queue<NativeCmdArgs> pending  = new ConcurrentLinkedQueue<NativeCmdArgs>();
+	static Queue<NativeCmdArgs> freeCmds = new ConcurrentLinkedQueue<NativeCmdArgs>();
+	
+	NativeCmdArgs getCmdArgs() {
+		NativeCmdArgs args = freeCmds.poll();
+		return args != null ? args : new NativeCmdArgs();
+	}
+
+	public void pushCmd(int cmd) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd = cmd;
+		pending.add(args);
+	}
+
+	public void pushCmd(int cmd, int a1) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd  = cmd;
+		args.arg1 = a1;
+		pending.add(args);
+	}
+
+	public void pushCmd(int cmd, int a1, int a2) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd  = cmd;
+		args.arg1 = a1;
+		args.arg2 = a2;
+		pending.add(args);
+	}
+	
+	public void pushCmd(int cmd, int a1, int a2, int a3, int a4) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd = cmd;
+		args.arg1 = a1;
+		args.arg2 = a2;
+		args.arg3 = a3;
+		args.arg4 = a4;
+		pending.add(args);
+	}
+
+	public void pushCmd(int cmd, String text) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd = cmd;
+		args.str = text;
+		pending.add(args);
+	}
+
+	public void pushCmd(int cmd, int a1, String str) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd  = cmd;
+		args.arg1 = a1;
+		args.str  = str;
+		pending.add(args);
+	}
+
+	public void pushCmd(int cmd, Surface surface) {
+		NativeCmdArgs args = getCmdArgs();
+		args.cmd = cmd;
+		args.sur = surface;
+		pending.add(args);
+	}
+	
+	public final static int CMD_KEY_DOWN = 0;
+	public final static int CMD_KEY_UP   = 1;
+	public final static int CMD_KEY_CHAR = 2;
+	public final static int CMD_POINTER_DOWN = 3;
+	public final static int CMD_POINTER_UP   = 4;
+	public final static int CMD_POINTER_MOVE = 5;
+
+	public final static int CMD_WIN_CREATED   = 6;
+	public final static int CMD_WIN_DESTROYED = 7;
+	public final static int CMD_WIN_RESIZED   = 8;
+	public final static int CMD_WIN_REDRAW    = 9;
+
+	public final static int CMD_APP_START   = 10;
+	public final static int CMD_APP_STOP    = 11;
+	public final static int CMD_APP_RESUME  = 12;
+	public final static int CMD_APP_PAUSE   = 13;
+	public final static int CMD_APP_DESTROY = 14;
+
+	public final static int CMD_GOT_FOCUS   = 15;
+	public final static int CMD_LOST_FOCUS  = 16;
+	public final static int CMD_CONFIG_CHANGED = 17;
+	public final static int CMD_LOW_MEMORY  = 18;
+
+	public final static int CMD_KEY_TEXT   = 19;
+	public final static int CMD_OFD_RESULT = 20;
+
+	public final static int CMD_UI_CREATED  = 21;
+	public final static int CMD_UI_CLICKED  = 22;
+	public final static int CMD_UI_CHANGED  = 23;
+	public final static int CMD_UI_STRING   = 24;
+
+	public final static int CMD_GPAD_AXISL  = 25;
+	public final static int CMD_GPAD_AXISR  = 26;
+	
+	
+	// ====================================================================
+	// ------------------------------ EVENTS ------------------------------
+	// ====================================================================
+	InputMethodManager input;
+	// static to persist across activity destroy/create
+	static boolean gameRunning;
+
+	void startGameAsync() {
+		Log.i("CC_WIN", "handing off to native..");
+		try {
+			System.loadLibrary("classicube");
+		} catch (UnsatisfiedLinkError ex) {
+			ex.printStackTrace();
+			showAlertAsync("Failed to start", ex.getMessage());
+			return;
+		}
+		
+		gameRunning = true;
+		runGameAsync();
+	}
+	
+	@Override
+	protected void onCreate(Bundle savedInstanceState) {
+		// requestWindowFeature - API level 1
+		// setSoftInputMode, SOFT_INPUT_STATE_UNSPECIFIED, SOFT_INPUT_ADJUST_RESIZE - API level 3
+		input = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+		Log.i("CC_WIN", "CREATE EVENT");
+		Window window = getWindow();
+		Log.i("CC_WIN", "GAME RUNNING?" + gameRunning);
+		//window.takeSurface(this);
+		//window.takeInputQueue(this);
+		// TODO: Should this be RGBA_8888??
+		window.setFormat(PixelFormat.RGBX_8888);
+		window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_UNSPECIFIED | WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+		requestWindowFeature(Window.FEATURE_NO_TITLE);
+		// TODO: rendering over display cutouts causes a problem where opening onscreen keyboard
+		//  stops resizing the game view. (e.g. meaning you can't see in-game chat input anymore)
+		//  Apparently intentional (see LayoutParams.SOFT_INPUT_ADJUST_RESIZE documentation)
+		// Need to find a solution that both renders over display cutouts and doesn't mess up onscreen input
+		// renderOverDisplayCutouts();
+		// TODO: semaphore for destroyed and surfaceDestroyed
+
+		if (!gameRunning) startGameAsync();
+		// TODO rethink to avoid this
+		if (gameRunning) updateInstance();
+		super.onCreate(savedInstanceState);
+	}
+
+	
+	/*@Override
+	public boolean dispatchKeyEvent(KeyEvent event) {
+		int action = event.getAction();
+		int code   = event.getKeyCode();
+		
+		if (action == KeyEvent.ACTION_DOWN) {
+			pushCmd(CMD_KEY_DOWN, keyCode);
+		
+			int keyChar = event.getUnicodeChar();
+			if (keyChar != 0) pushCmd(CMD_KEY_CHAR, keyChar);
+			return true;
+		} else if (action == KeyEvent.ACTION_UP) {
+			pushCmd(CMD_KEY_UP, keyCode);
+			return true;
+		}
+		return super.dispatchKeyEvent(event);
+	}*/
+	
+	void pushTouch(int cmd, MotionEvent event, int i) {
+		// getPointerId, getX, getY - API level 5
+		int id = event.getPointerId(i);
+		// TODO: Pass float to jni
+		int x  = (int)event.getX(i);
+		int y  = (int)event.getY(i);
+		pushCmd(cmd, id, x, y, 0);
+	}
+	
+	boolean handleTouchEvent(MotionEvent event) {
+		// getPointerCount - API level 5
+		// getActionMasked, getActionIndex - API level 8
+		switch (event.getActionMasked()) {
+		case MotionEvent.ACTION_DOWN:
+		case MotionEvent.ACTION_POINTER_DOWN:
+			pushTouch(CMD_POINTER_DOWN, event, event.getActionIndex());
+			break;
+			
+		case MotionEvent.ACTION_UP:
+		case MotionEvent.ACTION_POINTER_UP:
+			pushTouch(CMD_POINTER_UP, event, event.getActionIndex());
+			break;
+			
+		case MotionEvent.ACTION_MOVE:
+			for (int i = 0; i < event.getPointerCount(); i++) {
+				pushTouch(CMD_POINTER_MOVE, event, i);
+			}
+		}
+		return true;
+	}
+	
+	public boolean onKeyDown(int keyCode, KeyEvent event) {
+		// Ignore volume keys
+		if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) return false;
+		if (keyCode == KeyEvent.KEYCODE_VOLUME_MUTE) return false;
+		if (keyCode == KeyEvent.KEYCODE_VOLUME_UP)   return false;
+		
+		// TODO: not always handle (use Window_MapKey)
+		pushCmd(CMD_KEY_DOWN, keyCode);
+		
+		int keyChar = event.getUnicodeChar();
+		if (keyChar != 0) pushCmd(CMD_KEY_CHAR, keyChar);
+		return true;
+	}
+	
+	public boolean onKeyUp(int keyCode, KeyEvent event) {
+		// TODO: not always handle (use Window_MapKey)
+		pushCmd(CMD_KEY_UP, keyCode);
+		return true;
+	}
+	
+	@Override
+	protected void onStart() { 
+		super.onStart();  
+		pushCmd(CMD_APP_START); 
+	}
+	
+	@Override
+	protected void onStop() { 
+		super.onStop();
+		pushCmd(CMD_APP_STOP);
+		// In case game thread is blocked on showing dialog
+		releaseDialogSem();
+	}
+	
+	@Override
+	protected void onResume() {
+		attachSurface();
+		super.onResume();
+		pushCmd(CMD_APP_RESUME); 
+	}
+	
+	@Override
+	protected void onPause() {
+		// setContentView - API level 1
+		// Can't use null.. TODO is there a better way?
+		setContentView(new View(this));
+		super.onPause();
+		pushCmd(CMD_APP_PAUSE); 
+	}
+	
+	@Override
+	public void onWindowFocusChanged(boolean hasFocus) {
+		super.onWindowFocusChanged(hasFocus);
+		pushCmd(hasFocus ? CMD_GOT_FOCUS : CMD_LOST_FOCUS);
+	}
+	@Override
+	public void onConfigurationChanged(Configuration newConfig) {
+		super.onConfigurationChanged(newConfig);
+		//pushCmd(CMD_CONFIG_CHANGED);
+		// not needed because it's the surfaceChanged event that matters
+	}
+	@Override
+	public void onLowMemory() { super.onLowMemory(); pushCmd(CMD_LOW_MEMORY); }
+	
+	@Override
+	public void onDestroy() {
+		Log.i("CC_WIN", "APP DESTROYED");
+		super.onDestroy();
+		pushCmd(CMD_APP_DESTROY);
+	}
+	
+	// Called by the game thread to actually process events
+	public void processEvents() {
+		for (;;) {
+			NativeCmdArgs c = pending.poll();
+			if (c == null) return;
+			
+			switch (c.cmd) {
+			case CMD_KEY_DOWN: processKeyDown(c.arg1); break;
+			case CMD_KEY_UP:   processKeyUp(c.arg1);   break;
+			case CMD_KEY_CHAR: processKeyChar(c.arg1); break;
+			case CMD_KEY_TEXT: processKeyText(c.str);  break;
+	
+			case CMD_POINTER_DOWN: processPointerDown(c.arg1, c.arg2, c.arg3, c.arg4); break;
+			case CMD_POINTER_UP:   processPointerUp(  c.arg1, c.arg2, c.arg3, c.arg4); break;
+			case CMD_POINTER_MOVE: processPointerMove(c.arg1, c.arg2, c.arg3, c.arg4); break;
+
+			case CMD_GPAD_AXISL: processJoystickL(c.arg1, c.arg2); break;
+			case CMD_GPAD_AXISR: processJoystickR(c.arg1, c.arg2); break;
+
+			case CMD_WIN_CREATED:   processSurfaceCreated(c.sur);   break;
+			case CMD_WIN_DESTROYED: processSurfaceDestroyed();      break;
+			case CMD_WIN_RESIZED:   processSurfaceResized(c.sur);   break;
+			case CMD_WIN_REDRAW:    processSurfaceRedrawNeeded();   break;
+
+			case CMD_APP_START:   processOnStart();   break;
+			case CMD_APP_STOP:    processOnStop();    break;
+			case CMD_APP_RESUME:  processOnResume();  break;
+			case CMD_APP_PAUSE:   processOnPause();   break;
+			case CMD_APP_DESTROY: processOnDestroy(); break;
+
+			case CMD_GOT_FOCUS:	  processOnGotFocus();	  break;
+			case CMD_LOST_FOCUS:	 processOnLostFocus();	 break;
+			//case CMD_CONFIG_CHANGED: processOnConfigChanged(); break;
+			case CMD_LOW_MEMORY:	 processOnLowMemory();	 break;
+
+			case CMD_OFD_RESULT: processOFDResult(c.str); break;
+			}
+
+			c.str = null;
+			c.sur = null; // don't keep a reference to it
+			freeCmds.add(c);
+		}
+	}
+	
+	native void processKeyDown(int code);
+	native void processKeyUp(int code);
+	native void processKeyChar(int code);
+	native void processKeyText(String str);
+	
+	native void processPointerDown(int id, int x, int y, int isMouse);
+	native void processPointerUp(  int id, int x, int y, int isMouse);
+	native void processPointerMove(int id, int x, int y, int isMouse);
+
+	native void processJoystickL(int x, int y);
+	native void processJoystickR(int x, int y);
+
+	native void processSurfaceCreated(Surface sur);
+	native void processSurfaceDestroyed();
+	native void processSurfaceResized(Surface sur);
+	native void processSurfaceRedrawNeeded();
+
+	native void processOnStart();
+	native void processOnStop();
+	native void processOnResume();
+	native void processOnPause();
+	native void processOnDestroy();
+
+	native void processOnGotFocus();
+	native void processOnLostFocus();
+	//native void processOnConfigChanged();
+	native void processOnLowMemory();
+
+	native void processOFDResult(String path);
+	
+	native void runGameAsync();
+	native void updateInstance();
+	
+	
+	// ====================================================================
+	// ------------------------------ VIEWS -------------------------------
+	// ====================================================================
+	volatile boolean fullscreen;
+	// static to persist across activity destroy/create
+	static final Semaphore winDestroyedSem = new Semaphore(0, true);
+	SurfaceHolder.Callback callback;
+	public View curView;
+
+	public void setActiveView(View view) {
+		// setContentView, requestFocus - API level 1
+		curView = view;
+		setContentView(view);
+		curView.requestFocus();
+
+		if (fullscreen) setUIVisibility(FULLSCREEN_FLAGS);
+		hookMotionListener(view);
+	}
+	
+	void hookMotionListener(View view) {
+		try {
+			CCMotionListener listener = new CCMotionListener(this);
+			view.setOnGenericMotionListener(listener);
+		} catch (Exception ex) {
+			// Unsupported on android 12
+		}
+	}
+	
+	// SurfaceHolder.Callback - API level 1
+	class CCSurfaceCallback implements SurfaceHolder.Callback {
+		public void surfaceCreated(SurfaceHolder holder) {
+			// getSurface - API level 1
+			Log.i("CC_WIN", "win created " + holder.getSurface());
+			MainActivity.this.pushCmd(CMD_WIN_CREATED, holder.getSurface());
+		}
+		
+		public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+			// getSurface - API level 1
+			Log.i("CC_WIN", "win changed " + holder.getSurface());
+			MainActivity.this.pushCmd(CMD_WIN_RESIZED, holder.getSurface());
+		}
+		
+		public void surfaceDestroyed(SurfaceHolder holder) {
+			// getSurface, removeCallback - API level 1
+			Log.i("CC_WIN", "win destroyed " + holder.getSurface());
+			Log.i("CC_WIN", "cur view " + curView);
+			holder.removeCallback(this);
+			
+			//08-02 21:03:02.967: E/BufferQueueProducer(1350): [SurfaceView - com.classicube.ClassiCube/com.classicube.MainActivity#0] disconnect: not connected (req=2)
+			//08-02 21:03:02.968: E/SurfaceFlinger(1350): Failed to find layer (SurfaceView - com.classicube.ClassiCube/com.classicube.MainActivity#0) in layer parent (no-parent).
+	
+			MainActivity.this.pushCmd(CMD_WIN_DESTROYED);
+			// In case game thread is blocked showing a dialog on main thread
+			releaseDialogSem();
+			
+			// per the android docs for SurfaceHolder.Callback
+			// "If you have a rendering thread that directly accesses the surface, you must ensure
+			// that thread is no longer touching the Surface before returning from this function."
+			try {
+				winDestroyedSem.acquire();
+			} catch (InterruptedException e) { }
+		}
+	}
+	
+	// SurfaceHolder.Callback2 - API level 9
+	class CCSurfaceCallback2 extends CCSurfaceCallback implements SurfaceHolder.Callback2 {
+		public void surfaceRedrawNeeded(SurfaceHolder holder) {
+			// getSurface - API level 1
+			Log.i("CC_WIN", "win dirty " + holder.getSurface());
+			MainActivity.this.pushCmd(CMD_WIN_REDRAW);
+		}
+	}
+	
+	// Called by the game thread to notify the main thread
+	// that it is safe to destroy the window surface now
+	public void processedSurfaceDestroyed() { winDestroyedSem.release(); }
+	
+	void createSurfaceCallback() {
+		if (callback != null) return;
+		try {
+			callback = new CCSurfaceCallback2(); 
+		} catch (NoClassDefFoundError ex) {
+			ex.printStackTrace();
+			callback = new CCSurfaceCallback();
+		}
+	}
+	 
+	void attachSurface() {
+		// setContentView, requestFocus, getHolder, addCallback, RGBX_8888 - API level 1
+		createSurfaceCallback();
+		CCView view = new CCView(this);
+		view.getHolder().addCallback(callback);
+		view.getHolder().setFormat(PixelFormat.RGBX_8888);
+
+		setActiveView(view);
+	}
+	
+	
+	// ==================================================================
+	// ---------------------------- PLATFORM ----------------------------
+	// ==================================================================
+	//  Implements java Android side of the Android Platform backend (See Platform.c)
+	public void setupForGame() {
+		// Once a surface has been locked for drawing with canvas, can't ever be detached
+		// This means trying to attach an OpenGL ES context to the surface will fail
+		// So just destroy the current surface and make a new one
+		runOnUiThread(new Runnable() {
+			public void run() {
+				attachSurface();
+			}
+		});
+	}
+	
+	public void startOpen(String url) {
+		// ACTION_VIEW, resolveActivity, getPackageManager, startActivity - API level 1
+		Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
+		if (intent.resolveActivity(getPackageManager()) != null) {
+			startActivity(intent);
+		}
+	}
+	
+	public String getGameDataDirectory() {
+		// getExternalFilesDir - API level 8
+		return getExternalFilesDir(null).getAbsolutePath();
+	}
+
+	public String getGameCacheDirectory() {
+		// getExternalCacheDir - API level 8
+		File root = getExternalCacheDir();
+		if (root != null) return root.getAbsolutePath();
+
+		// although exceedingly rare, getExternalCacheDir() can technically fail
+		//   "... May return null if shared storage is not currently available."
+		// getCacheDir - API level 1
+		return getCacheDir().getAbsolutePath();
+	}
+	
+	public String getUUID() {
+		// getContentResolver - API level 1
+		// getString, ANDROID_ID - API level 3
+		return Secure.getString(getContentResolver(), Secure.ANDROID_ID);
+	}
+	
+	public long getApkUpdateTime() {
+		try {
+			// getApplicationInfo - API level 4
+			ApplicationInfo info = getApplicationInfo();
+			File apkFile = new File(info.sourceDir);
+			
+			// https://developer.android.com/reference/java/io/File#lastModified()
+			//  lastModified is returned in milliseconds
+			return apkFile.lastModified() / 1000;
+		} catch (Exception ex) {
+			return 0;
+		}
+	}
+	
+
+	// ====================================================================
+	// ------------------------------ WINDOW ------------------------------
+	// ====================================================================
+	//  Implements java Android side of the Android Window backend (See Window.c)
+	volatile int keyboardType;
+	volatile String keyboardText = "";
+	// setTitle - API level 1
+	public void setWindowTitle(String str) { setTitle(str); }
+
+	public void openKeyboard(String text, int flags) {
+		// restartInput, showSoftInput - API level 3
+		keyboardType = flags;
+		keyboardText = text;
+		//runOnUiThread(new Runnable() {
+			//public void run() {
+				// Restart view so it uses the right INPUT_TYPE
+				if (curView != null) input.restartInput(curView);
+				if (curView != null) input.showSoftInput(curView, 0);
+			//}
+		//});
+	}
+
+	public void closeKeyboard() {
+		// InputMethodManager, hideSoftInputFromWindow - API level 3
+		// getWindow, getDecorView, getWindowToken - API level 1
+		InputMethodManager input = (InputMethodManager)getSystemService(Context.INPUT_METHOD_SERVICE);
+		View view = getWindow().getDecorView();
+		input.hideSoftInputFromWindow(view.getWindowToken(), 0);
+		keyboardText = "";
+		//runOnUiThread(new Runnable() {
+			//public void run() {
+				if (curView != null) input.hideSoftInputFromWindow(curView.getWindowToken(), 0);
+			//}
+		//});
+	}
+
+	public void setKeyboardText(String text) {
+		keyboardText = text;
+		// Restart view because text changed externally
+		if (curView == null) return;
+
+		// Try to avoid restarting input if possible
+		CCView view = (CCView)curView;
+		if (view.kbText != null) {
+			String curText = view.kbText.toString();
+			if (text.equals(curText)) return;
+		}
+
+		// Have to restart input because text changed externally
+		// NOTE: Doing this still has issues, like changing keyboard tab back to default one,
+		//   and one user has a problem where it also resets letters to uppercase
+		// TODO: Consider just doing kbText.replace instead
+		// (see https://chromium.googlesource.com/chromium/src/+/d1421a5faf9dc2d3b3cad10640576b24a092d9ba/content/public/android/java/src/org/chromium/content/browser/input/AdapterInputConnection.java)
+		input.restartInput(curView);
+	}
+
+	public static int calcKeyboardType(int kbType) {
+		// TYPE_CLASS_TEXT, TYPE_CLASS_NUMBER, TYPE_TEXT_VARIATION_PASSWORD - API level 3
+		int type = kbType & 0xFF;
+		
+		if (type == 2) return InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD;
+		if (type == 1) return InputType.TYPE_CLASS_NUMBER; // KEYBOARD_TYPE_NUMERIC
+		if (type == 3) return InputType.TYPE_CLASS_NUMBER; // KEYBOARD_TYPE_INTEGER
+		return InputType.TYPE_CLASS_TEXT;
+	}
+	
+	public static int calcKeyboardOptions(int kbType) {
+		// IME_ACTION_GO, IME_FLAG_NO_EXTRACT_UI - API level 3
+		if ((kbType & 0x100) != 0) {
+			return EditorInfo.IME_ACTION_SEND | EditorInfo.IME_FLAG_NO_EXTRACT_UI;
+		} else {
+			return EditorInfo.IME_ACTION_GO   | EditorInfo.IME_FLAG_NO_EXTRACT_UI;
+		}
+	}
+
+	public String getClipboardText() {
+		// ClipboardManager, getText() - API level 11
+		ClipboardManager clipboard = (ClipboardManager)getSystemService(Context.CLIPBOARD_SERVICE);
+		CharSequence chars = clipboard.getText();
+		return chars == null ? null : chars.toString();
+	}
+
+	public void setClipboardText(String str) {
+		// ClipboardManager, setText() - API level 11
+		ClipboardManager clipboard = (ClipboardManager)getSystemService(Context.CLIPBOARD_SERVICE);
+		clipboard.setText(str);
+	}
+	
+	DisplayMetrics getMetrics() {
+		// getDefaultDisplay, getMetrics - API level 1
+		DisplayMetrics dm = new DisplayMetrics();
+		getWindowManager().getDefaultDisplay().getMetrics(dm);
+		return dm;
+	}
+
+	// Using raw DPI gives values such as 1.86, 3.47, 1.62.. not exactly ideal
+	// One device also gave differing x/y DPI which stuffs up the movement overlay
+	public float getDpiX() { return getMetrics().density; }
+	public float getDpiY() { return getMetrics().density; }
+
+	final Semaphore dialogSem = new Semaphore(0, true);
+	
+	void releaseDialogSem() {
+		// Only release when no waiting threads (otherwise showAlert doesn't block when called)
+		if (!dialogSem.hasQueuedThreads()) return;
+		dialogSem.release();
+	}
+
+	void renderOverDisplayCutouts() {
+		// FLAG_TRANSLUCENT_STATUS - API level 19
+		// layoutInDisplayCutoutMode - API level 28
+		// LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - API level 28
+		try {
+			Window window = getWindow();
+			window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
+			window.getAttributes().layoutInDisplayCutoutMode =
+				WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
+		} catch (NoSuchFieldError ex) {
+			ex.printStackTrace();
+		} catch (NoSuchMethodError ex) {
+			ex.printStackTrace();
+		}
+	}
+
+	void showAlertAsync(final String title, final String message) {
+		//final Activity activity = this;
+		// setTitle, setMessage, setPositiveButton, setCancelable, create, show - API level 1
+		runOnUiThread(new Runnable() {
+			public void run() {
+				AlertDialog.Builder dlg = new AlertDialog.Builder(MainActivity.this);
+				dlg.setTitle(title);
+				dlg.setMessage(message);
+				
+				dlg.setPositiveButton("Close", new DialogInterface.OnClickListener() {
+					public void onClick(DialogInterface dialog, int id) { releaseDialogSem(); }
+				});
+				dlg.setCancelable(false);
+				dlg.create().show();
+			}
+		});
+	}
+
+	
+	public void showAlert(final String title, final String message) {
+		showAlertAsync(title, message);
+		try {
+			dialogSem.acquire(); // Block game thread
+		} catch (InterruptedException e) { }
+	}
+
+	public int getWindowState() { return fullscreen ? 1 : 0; }
+	// SYSTEM_UI_FLAG_HIDE_NAVIGATION - API level 14
+	// SYSTEM_UI_FLAG_FULLSCREEN - API level 16
+	// SYSTEM_UI_FLAG_IMMERSIVE_STICKY - API level 19
+	final static int FULLSCREEN_FLAGS = View.SYSTEM_UI_FLAG_FULLSCREEN | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
+	
+	void setUIVisibility(int flags) {
+		if (curView == null) return;
+		// setSystemUiVisibility - API level 11
+		try {
+			curView.setSystemUiVisibility(flags);
+		} catch (NoSuchMethodError ex) {
+			ex.printStackTrace();
+		}
+	}
+
+	public void enterFullscreen() {
+		fullscreen = true;
+		runOnUiThread(new Runnable() {
+			public void run() { setUIVisibility(FULLSCREEN_FLAGS); }
+		});
+    }
+
+    public void exitFullscreen() {
+		fullscreen = false;
+		runOnUiThread(new Runnable() {
+			public void run() { setUIVisibility(View.SYSTEM_UI_FLAG_VISIBLE); }
+		});
+    }
+	
+	public String shareScreenshot(String path) {
+		try {
+			Uri uri;
+			if (android.os.Build.VERSION.SDK_INT >= 23){ // android 6.0
+				uri = CCFileProvider.getUriForFile("screenshots/" + path);
+			} else {
+				// when trying to use content:// URIs on my android 4.0.3 test device
+				//   - 1 app crashed
+				//   - 1 app wouldn't show image previews
+				// so fallback to file:// on older devices as they seem to reliably work
+				File file = new File(getGameDataDirectory() + "/screenshots/" + path);
+				uri = Uri.fromFile(file);
+			}
+			Intent intent = new Intent();
+			
+			intent.setAction(Intent.ACTION_SEND);
+			intent.putExtra(Intent.EXTRA_STREAM, uri);
+			intent.setType("image/png");
+			intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+			startActivity(Intent.createChooser(intent, "share via"));
+		} catch (Exception ex) {
+			return ex.toString();
+		}
+		return "";
+	}
+
+
+	static String uploadFolder, savePath;
+	final static int OPEN_REQUEST = 0x4F50454E;
+	final static int SAVE_REQUEST = 0x53415645;
+
+	// https://stackoverflow.com/questions/36557879/how-to-use-native-android-file-open-dialog
+	// https://developer.android.com/guide/topics/providers/document-provider
+	// https://developer.android.com/training/data-storage/shared/documents-files#java
+	// https://stackoverflow.com/questions/5657411/android-getting-a-file-uri-from-a-content-uri
+	public int openFileDialog(String folder) {
+		uploadFolder = folder;
+
+		try {
+			Intent intent = new Intent()
+					.setType("*/*")
+					.setAction(Intent.ACTION_GET_CONTENT);
+
+			startActivityForResult(Intent.createChooser(intent, "Select a file"), OPEN_REQUEST);
+		} catch (Exception ex) {
+			ex.printStackTrace();
+			return 0;// TODO log error to in-game
+		}
+		return 1;
+	}
+
+	// https://stackoverflow.com/questions/8586691/how-to-open-file-save-dialog-in-android
+	public int saveFileDialog(String path, String name) {
+		savePath = path;
+
+		try {
+			Intent intent = new Intent()
+					.setType("/")
+					.addCategory(Intent.CATEGORY_OPENABLE)
+					.setAction(Intent.ACTION_CREATE_DOCUMENT)
+					.setType("application/octet-stream")
+					.putExtra(Intent.EXTRA_TITLE, name);
+
+			startActivityForResult(Intent.createChooser(intent, "Choose destination"), SAVE_REQUEST);
+		} catch (Exception ex) {
+			ex.printStackTrace();
+			return 0;// TODO log error to in-game
+		}
+		return 1;
+	}
+
+	@Override
+	protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+		super.onActivityResult(requestCode, resultCode, data);
+		if (resultCode != RESULT_OK) return;
+
+		if (requestCode == OPEN_REQUEST) {
+			handleOpenResult(data);
+		} else if (requestCode == SAVE_REQUEST) {
+			handleSaveResult(data);
+		}
+	}
+
+	void handleSaveResult(Intent data) {
+		try {
+			Uri selected = data.getData();
+			saveTempToContent(selected, savePath);
+		} catch (Exception ex) {
+			ex.printStackTrace();
+			// TODO log error to in-game
+		}
+	}
+
+	void saveTempToContent(Uri uri, String path) throws IOException {
+		File file = new File(getGameDataDirectory() + "/" + path);
+		OutputStream output = null;
+		InputStream input   = null;
+
+		try {
+			input  = new FileInputStream(file);
+			output = getContentResolver().openOutputStream(uri);
+			copyStreamData(input, output);
+			file.delete();
+		} finally {
+			if (output != null) output.close();
+			if (input != null)  input.close();
+		}
+	}
+
+	void handleOpenResult(Intent data) {
+		try {
+			Uri selected = data.getData();
+			String name  = getContentFilename(selected);
+			String path  = saveContentToTemp(selected, uploadFolder, name);
+			pushCmd(CMD_OFD_RESULT, uploadFolder + "/" + name);
+		} catch (Exception ex) {
+			ex.printStackTrace();
+			// TODO log error to in-game
+		}
+	}
+
+	String getContentFilename(Uri uri) {
+		Cursor cursor = getContentResolver().query(uri, new String[] { OpenableColumns.DISPLAY_NAME }, null, null, null);
+		if (cursor != null && cursor.moveToFirst()) {
+			int cIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
+			if (cIndex != -1) return cursor.getString(cIndex);
+		}
+		return null;
+	}
+
+	String saveContentToTemp(Uri uri, String folder, String name) throws IOException {
+		//File file = new File(getExternalFilesDir(null), folder + "/" + name);
+		File file = new File(getGameDataDirectory() + "/" + folder + "/" + name);
+		file.getParentFile().mkdirs();
+
+		OutputStream output = null;
+		InputStream input   = null;
+
+		try {
+			output = new FileOutputStream(file);
+			input  = getContentResolver().openInputStream(uri);
+			copyStreamData(input, output);
+		} finally {
+			if (output != null) output.close();
+			if (input != null)  input.close();
+		}
+		return file.getAbsolutePath();
+	}
+
+	static void copyStreamData(InputStream input, OutputStream output) throws IOException {
+		byte[] temp = new byte[8192];
+		int length;
+		while ((length = input.read(temp)) > 0)
+			output.write(temp, 0, length);
+	}
+
+
+	// ======================================================================
+	// -------------------------------- HTTP --------------------------------
+	// ======================================================================
+	//  Implements java Android side of the Android HTTP backend (See Http.c)
+	static HttpURLConnection conn;
+	static InputStream src;
+	static byte[] readCache = new byte[8192];
+
+	public static int httpInit(String url, String method) {
+		try {
+			conn = (HttpURLConnection)new URL(url).openConnection();
+			conn.setDoInput(true);
+			conn.setRequestMethod(method);
+			conn.setInstanceFollowRedirects(true);
+			
+			httpAddMethodHeaders(method);
+			return 0;
+		} catch (Exception ex) {
+			return httpOnError(ex);
+		}
+	}
+	
+	static void httpAddMethodHeaders(String method) {
+		if (!method.equals("HEAD")) return;
+
+		// Ever since dropbox switched to to chunked transfer encoding,
+		//  sending a HEAD request to dropbox always seems to result in the
+		//  next GET request failing with 'Unexpected status line' ProtocolException
+		// Seems to be a known issue: https://github.com/square/okhttp/issues/3689
+		// Simplest workaround is to ask for connection to be closed after HEAD request
+		httpSetHeader("connection", "close");
+	}
+
+	public static void httpSetHeader(String name, String value) {
+		conn.setRequestProperty(name, value);
+	}
+
+	public static int httpSetData(byte[] data) {
+		try {
+			conn.setDoOutput(true);
+			conn.getOutputStream().write(data);
+			conn.getOutputStream().flush();
+			return 0;
+		} catch (Exception ex) {
+			return httpOnError(ex);
+		}
+	}
+
+	public static int httpPerform() {
+		int len;
+		try {
+			conn.connect();
+			// Some implementations also provide this as getHeaderField(0), but some don't
+			httpParseHeader("HTTP/1.1 " + conn.getResponseCode() + " MSG");
+			
+			// Legitimate webservers aren't going to reply with over 200 headers
+			for (int i = 0; i < 200; i++) {
+				String key = conn.getHeaderFieldKey(i);
+				String val = conn.getHeaderField(i);
+				if (key == null && val == null) break;
+				
+				if (key == null) {
+					httpParseHeader(val);
+				} else {
+					httpParseHeader(key + ":" + val);
+				}
+			}
+
+			src = conn.getInputStream();
+			while ((len = src.read(readCache)) > 0) {
+				httpAppendData(readCache, len);
+			}
+
+			httpFinish();
+			return 0;
+		} catch (Exception ex) {
+			return httpOnError(ex);
+		}
+	}
+
+	static void httpFinish() {
+		conn = null;
+		try {
+			src.close();
+		} catch (Exception ex) { }
+		src = null;
+	}
+
+	// TODO: Should we prune this list?
+	static List<String> errors = new ArrayList<String>();
+
+	static int httpOnError(Exception ex) {
+		ex.printStackTrace();
+		httpFinish();
+		errors.add(ex.getMessage());
+		return -errors.size(); // don't want 0 as an error code
+	}
+
+	public static String httpDescribeError(int res) {
+		res = -res - 1;
+		return res >= 0 && res < errors.size() ? errors.get(res) : null;
+	}
+
+	native static void httpParseHeader(String header);
+	native static void httpAppendData(byte[] data, int len);
+}