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);
}