summary refs log tree commit diff
path: root/android
diff options
context:
space:
mode:
Diffstat (limited to 'android')
-rw-r--r--android/app/CMakeLists.txt115
-rw-r--r--android/app/build.gradle29
-rw-r--r--android/app/src/main/AndroidManifest.xml32
-rw-r--r--android/app/src/main/java/com/classicube/CCFileProvider.java115
-rw-r--r--android/app/src/main/java/com/classicube/CCMotionListener.java57
-rw-r--r--android/app/src/main/java/com/classicube/CCView.java108
-rw-r--r--android/app/src/main/java/com/classicube/MainActivity.java1012
-rw-r--r--android/app/src/main/res/mipmap-hdpi/ccicon.pngbin0 -> 2164 bytes
-rw-r--r--android/app/src/main/res/mipmap-mdpi/ccicon.pngbin0 -> 1415 bytes
-rw-r--r--android/app/src/main/res/mipmap-xhdpi/ccicon.pngbin0 -> 2561 bytes
-rw-r--r--android/app/src/main/res/mipmap-xxhdpi/ccicon.pngbin0 -> 4991 bytes
-rw-r--r--android/app/src/main/res/values/strings.xml3
-rw-r--r--android/build.gradle21
-rw-r--r--android/gradle.properties20
-rw-r--r--android/gradle/wrapper/gradle-wrapper.jarbin0 -> 49896 bytes
-rw-r--r--android/gradle/wrapper/gradle-wrapper.properties6
-rw-r--r--android/gradlew164
-rw-r--r--android/gradlew.bat90
-rw-r--r--android/settings.gradle2
19 files changed, 1774 insertions, 0 deletions
diff --git a/android/app/CMakeLists.txt b/android/app/CMakeLists.txt
new file mode 100644
index 0000000..ef21c0e
--- /dev/null
+++ b/android/app/CMakeLists.txt
@@ -0,0 +1,115 @@
+#
+# Copyright (C) The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+cmake_minimum_required(VERSION 3.4.1)
+
+# build native_app_glue as a static lib
+set(${CMAKE_C_FLAGS}, "${CMAKE_C_FLAGS}")
+# now build app's shared lib
+set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++11 -Wall -Werror")
+
+add_library(classicube SHARED
+        ../../src/main.c
+        ../../src/IsometricDrawer.c
+        ../../src/Builder.c
+        ../../src/ExtMath.c
+        ../../src/_ftbitmap.c
+        ../../src/Utils.c
+        ../../src/Camera.c
+        ../../src/Game.c
+        ../../src/GameVersion.c
+        ../../src/Window_Android.c
+        ../../src/_ftbase.c
+        ../../src/Graphics_GL2.c
+        ../../src/Deflate.c
+        ../../src/_cff.c
+        ../../src/_ftsynth.c
+        ../../src/String.c
+        ../../src/LWidgets.c
+        ../../src/Options.c
+        ../../src/Drawer2D.c
+        ../../src/Server.c
+        ../../src/Entity.c
+        ../../src/Drawer.c
+        ../../src/Vorbis.c
+        ../../src/Protocol.c
+        ../../src/World.c
+        ../../src/SelOutlineRenderer.c
+        ../../src/Platform_Posix.c
+        ../../src/Platform_Android.c
+        ../../src/LScreens.c
+        ../../src/_truetype.c
+        ../../src/_ftglyph.c
+        ../../src/Model.c
+        ../../src/_autofit.c
+        ../../src/Vectors.c
+        ../../src/HeldBlockRenderer.c
+        ../../src/Inventory.c
+        ../../src/Launcher.c
+        ../../src/Block.c
+        ../../src/LWeb.c
+        ../../src/Stream.c
+        ../../src/Lighting.c
+        ../../src/Resources.c
+        ../../src/PackedCol.c
+        ../../src/Screens.c
+        ../../src/Formats.c
+        ../../src/_sfnt.c
+        ../../src/Bitmap.c
+        ../../src/EntityComponents.c
+        ../../src/_pshinter.c
+        ../../src/Http_Worker.c
+        ../../src/MapRenderer.c
+        ../../src/Audio.c
+        ../../src/_ftinit.c
+        ../../src/Event.c
+        ../../src/Logger.c
+        ../../src/Widgets.c
+        ../../src/TexturePack.c
+        ../../src/Menus.c
+        ../../src/BlockPhysics.c
+        ../../src/_psmodule.c
+        ../../src/Chat.c
+        ../../src/Gui.c
+        ../../src/AxisLinesRenderer.c
+        ../../src/Picking.c
+        ../../src/_type1.c
+        ../../src/_smooth.c
+        ../../src/_psaux.c
+        ../../src/Generator.c
+        ../../src/Input.c
+        ../../src/Particle.c
+        ../../src/Physics.c
+        ../../src/SelectionBox.c
+        ../../src/EnvRenderer.c
+        ../../src/Animations.c
+        ../../src/LBackend.c
+        ../../src/SystemFonts.c
+        ../../src/Commands.c
+        ../../src/EntityRenderers.c
+        ../../src/AudioBackend.c
+        ../../src/TouchUI.c
+        ../../src/LBackend_Android.c
+        )
+
+# add lib dependencies
+target_link_libraries(classicube
+    android
+    EGL
+    GLESv2
+    log
+    OpenSLES
+    jnigraphics)
diff --git a/android/app/build.gradle b/android/app/build.gradle
new file mode 100644
index 0000000..516e09a
--- /dev/null
+++ b/android/app/build.gradle
@@ -0,0 +1,29 @@
+apply plugin: 'com.android.application'
+
+android {
+    compileSdkVersion 28
+
+    defaultConfig {
+        applicationId = 'com.classicube.android.client'
+        minSdkVersion 13
+        targetSdkVersion 26
+        externalNativeBuild {
+            cmake {
+                arguments '-DANDROID_STL=c++_static'
+            }
+        }
+    }
+    buildTypes {
+        release {
+            minifyEnabled false
+            proguardFiles getDefaultProguardFile('proguard-android.txt'),
+                    'proguard-rules.pro'
+        }
+    }
+    externalNativeBuild {
+        cmake {
+            version '3.10.2'
+            path 'CMakeLists.txt'
+        }
+    }
+}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..5285e9e
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- BEGIN_INCLUDE(manifest) -->
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="com.classicube.android.client"
+          android:versionCode="1360"
+          android:versionName="1.3.6">
+
+  <uses-permission android:name="android.permission.INTERNET" />
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="18" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="18" />
+  <uses-sdk android:minSdkVersion="8" android:targetSdkVersion="26"/>
+  
+  <application android:icon="@mipmap/ccicon" android:label="ClassiCube">
+    <provider
+        android:name="com.classicube.CCFileProvider"
+        android:authorities="com.classicube.android.client.provider"
+        android:exported="false"
+        android:grantUriPermissions="true" >
+    </provider>
+
+    <activity android:name="com.classicube.MainActivity" android:label="ClassiCube"
+              android:configChanges="orientation|screenSize|keyboard|keyboardHidden">
+
+      <intent-filter>
+        <action android:name="android.intent.action.MAIN" />
+        <category android:name="android.intent.category.LAUNCHER" />
+      </intent-filter>
+    </activity>
+  </application>
+
+</manifest>
+<!-- END_INCLUDE(manifest) -->
diff --git a/android/app/src/main/java/com/classicube/CCFileProvider.java b/android/app/src/main/java/com/classicube/CCFileProvider.java
new file mode 100644
index 0000000..a0883a0
--- /dev/null
+++ b/android/app/src/main/java/com/classicube/CCFileProvider.java
@@ -0,0 +1,115 @@
+package com.classicube;
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.util.ArrayList;
+
+import android.content.ContentProvider;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.pm.ProviderInfo;
+import android.database.Cursor;
+import android.database.MatrixCursor;
+import android.net.Uri;
+import android.os.ParcelFileDescriptor;
+import android.provider.MediaStore;
+import android.provider.OpenableColumns;
+
+public class CCFileProvider extends ContentProvider
+{
+    final static String[] DEFAULT_COLUMNS = { OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE, MediaStore.MediaColumns.DATA };
+    File root;
+
+    @Override
+    public boolean onCreate() {
+        return true;
+    }
+
+    @Override
+    public void attachInfo(Context context, ProviderInfo info) {
+        super.attachInfo(context, info);
+        root = context.getExternalFilesDir(null); // getGameDataDirectory
+    }
+
+    @Override
+    public Cursor query(Uri uri, String[] projection, String selection, String[] selectionArgs, String sortOrder) {
+        File file = getFileForUri(uri);
+        // can be null when caller is requesting all columns
+        if (projection == null) projection = DEFAULT_COLUMNS;
+
+        ArrayList<String> cols = new ArrayList<String>(3);
+        ArrayList<Object> vals = new ArrayList<Object>(3);
+
+        for (String column : projection) {
+            if (column.equals(OpenableColumns.DISPLAY_NAME)) {
+                cols.add(OpenableColumns.DISPLAY_NAME);
+                vals.add(file.getName());
+            } else if (column.equals(OpenableColumns.SIZE)) {
+                cols.add(OpenableColumns.SIZE);
+                vals.add(file.length());
+            } else if (column.equals(MediaStore.MediaColumns.DATA)) {
+                cols.add(MediaStore.MediaColumns.DATA);
+                vals.add(file.getAbsolutePath());
+            }
+        }
+
+        // https://stackoverflow.com/questions/4042434/converting-arrayliststring-to-string-in-java
+        MatrixCursor cursor = new MatrixCursor(cols.toArray(new String[0]), 1);
+        cursor.addRow(vals.toArray());
+        return cursor;
+    }
+
+    @Override
+    public String getType(Uri uri) {
+        String path = uri.getEncodedPath();
+        int sepExt  = path.lastIndexOf('.');
+
+        if (sepExt >= 0) {
+            String fileExt = path.substring(sepExt);
+            if (fileExt.equals(".png")) return "image/png";
+        }
+        return "application/octet-stream";
+    }
+
+    @Override
+    public Uri insert(Uri uri, ContentValues values) {
+        throw new UnsupportedOperationException("Readonly access");
+    }
+
+    @Override
+    public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException("Readonly access");
+    }
+
+    @Override
+    public int delete(Uri uri, String selection, String[] selectionArgs) {
+        throw new UnsupportedOperationException("Readonly access");
+    }
+
+    @Override
+    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
+        File file = getFileForUri(uri);
+        return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY);
+    }
+
+    public static Uri getUriForFile(String path) {
+        // See AndroidManifest.xml for authority
+        return new Uri.Builder()
+                .scheme("content")
+                .authority("com.classicube.android.client.provider")
+                .encodedPath(Uri.encode(path, "/"))
+                .build();
+    }
+
+    File getFileForUri(Uri uri) {
+        String path = uri.getPath();
+        File file   = new File(root, path);
+
+        file = file.getAbsoluteFile();
+        // security validation check
+        if (!file.getPath().startsWith(root.getPath())) {
+            throw new SecurityException("Resolved path lies outside app directory:" + path);
+        }
+        return file;
+    }
+}
diff --git a/android/app/src/main/java/com/classicube/CCMotionListener.java b/android/app/src/main/java/com/classicube/CCMotionListener.java
new file mode 100644
index 0000000..cd7ba61
--- /dev/null
+++ b/android/app/src/main/java/com/classicube/CCMotionListener.java
@@ -0,0 +1,57 @@
+package com.classicube;
+import android.view.InputDevice;
+import android.view.MotionEvent;
+import android.view.View;
+
+public class CCMotionListener implements View.OnGenericMotionListener {
+    MainActivity activity;
+
+    public CCMotionListener(MainActivity activity) {
+        this.activity = activity;
+    }
+
+    // https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input#java
+    @Override
+    public boolean onGenericMotion(View view, MotionEvent event) {
+        if (event.getAction() != MotionEvent.ACTION_MOVE) return false;
+        boolean source_joystick = (event.getSource() & InputDevice.SOURCE_JOYSTICK) == InputDevice.SOURCE_JOYSTICK;
+        boolean source_gamepad  = (event.getSource() & InputDevice.SOURCE_GAMEPAD)  == InputDevice.SOURCE_GAMEPAD;
+
+        if (source_joystick || source_gamepad) {
+            int historySize = event.getHistorySize();
+            for (int i = 0; i < historySize; i++) {
+                processJoystickInput(event, i);
+            }
+
+            processJoystickInput(event, -1);
+            return true;
+        }
+        return false;
+    }
+
+    void processJoystickInput(MotionEvent event, int historyPos) {
+        float x1 = getAxisValue(event, MotionEvent.AXIS_X,  historyPos);
+        float y1 = getAxisValue(event, MotionEvent.AXIS_Y,  historyPos);
+
+        float x2 = getAxisValue(event, MotionEvent.AXIS_Z,  historyPos);
+        float y2 = getAxisValue(event, MotionEvent.AXIS_RZ, historyPos);
+
+        if (x1 != 0 || y1 != 0)
+            pushAxisMovement(MainActivity.CMD_GPAD_AXISL, x1, y1);
+        if (x2 != 0 || y2 != 0)
+            pushAxisMovement(MainActivity.CMD_GPAD_AXISR, x2, y2);
+    }
+
+    float getAxisValue(MotionEvent event, int axis, int historyPos) {
+        float value = historyPos < 0 ? event.getAxisValue(axis) :
+                        event.getHistoricalAxisValue(axis, historyPos);
+
+        // Deadzone detection
+        if (value >= -0.25f && value <= 0.25f) value = 0;
+        return value;
+    }
+
+    void pushAxisMovement(int axis, float x, float y) {
+        activity.pushCmd(axis, (int)(x * 4096), (int)(y * 4096));
+    }
+}
diff --git a/android/app/src/main/java/com/classicube/CCView.java b/android/app/src/main/java/com/classicube/CCView.java
new file mode 100644
index 0000000..b42d924
--- /dev/null
+++ b/android/app/src/main/java/com/classicube/CCView.java
@@ -0,0 +1,108 @@
+package com.classicube;
+import android.text.Editable;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.SurfaceView;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+public class CCView extends SurfaceView {
+    SpannableStringBuilder kbText;
+    MainActivity activity;
+
+    public CCView(MainActivity activity) {
+        // setFocusable, setFocusableInTouchMode - API level 1
+        super(activity);
+        this.activity = activity;
+        setFocusable(true);
+        setFocusableInTouchMode(true);
+    }
+
+    @Override
+    public boolean dispatchTouchEvent(MotionEvent ev) {
+        return activity.handleTouchEvent(ev) || super.dispatchTouchEvent(ev);
+    }
+
+    @Override
+    public InputConnection onCreateInputConnection(EditorInfo attrs) {
+        // BaseInputConnection, IME_ACTION_GO, IME_FLAG_NO_EXTRACT_UI - API level 3
+        attrs.actionLabel = null;
+        attrs.inputType   = MainActivity.calcKeyboardType(activity.keyboardType);
+        attrs.imeOptions  = MainActivity.calcKeyboardOptions(activity.keyboardType);
+
+        kbText = new SpannableStringBuilder(activity.keyboardText);
+
+        InputConnection ic = new BaseInputConnection(this, true) {
+            boolean inited;
+
+            void updateText() {
+                activity.pushCmd(MainActivity.CMD_KEY_TEXT, kbText.toString());
+            }
+
+            @Override
+            public Editable getEditable() {
+                if (!inited) {
+                    // needed to set selection, otherwise random crashes later with backspacing
+                    // set selection to end, so backspacing after opening keyboard with text still works
+                    Selection.setSelection(kbText, kbText.toString().length());
+                    inited = true;
+                }
+                return kbText;
+            }
+
+            @Override
+            public boolean setComposingText(CharSequence text, int newCursorPosition) {
+                boolean success = super.setComposingText(text, newCursorPosition);
+                updateText();
+                return success;
+            }
+
+            @Override
+            public boolean deleteSurroundingText(int beforeLength, int afterLength) {
+                boolean success = super.deleteSurroundingText(beforeLength, afterLength);
+                updateText();
+                return success;
+            }
+
+            @Override
+            public boolean commitText(CharSequence text, int newCursorPosition) {
+                boolean success = super.commitText(text, newCursorPosition);
+                updateText();
+                return success;
+            }
+
+            @Override
+            public boolean sendKeyEvent(KeyEvent ev) {
+                // getSelectionStart - API level 1
+                if (ev.getAction() != KeyEvent.ACTION_DOWN) return super.sendKeyEvent(ev);
+                int code = ev.getKeyCode();
+                int uni = ev.getUnicodeChar();
+
+                // start is -1 sometimes, and trying to insert/delete there crashes
+                int start = Selection.getSelectionStart(kbText);
+                if (start == -1) start = kbText.toString().length();
+
+                if (code == KeyEvent.KEYCODE_ENTER) {
+                    // enter maps to \n but that should not be intercepted
+                } else if (code == KeyEvent.KEYCODE_DEL) {
+                    if (start <= 0) return false;
+                    kbText.delete(start - 1, start);
+                    updateText();
+                    return false;
+                } else if (uni != 0) {
+                    kbText.insert(start, String.valueOf((char) uni));
+                    updateText();
+                    return false;
+                }
+                return super.sendKeyEvent(ev);
+            }
+
+        };
+        //String text = MainActivity.this.keyboardText;
+        //if (text != null) ic.setComposingText(text, 0);
+        return ic;
+    }
+}
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);
+}
diff --git a/android/app/src/main/res/mipmap-hdpi/ccicon.png b/android/app/src/main/res/mipmap-hdpi/ccicon.png
new file mode 100644
index 0000000..6279a4d
--- /dev/null
+++ b/android/app/src/main/res/mipmap-hdpi/ccicon.png
Binary files differdiff --git a/android/app/src/main/res/mipmap-mdpi/ccicon.png b/android/app/src/main/res/mipmap-mdpi/ccicon.png
new file mode 100644
index 0000000..7d7a53c
--- /dev/null
+++ b/android/app/src/main/res/mipmap-mdpi/ccicon.png
Binary files differdiff --git a/android/app/src/main/res/mipmap-xhdpi/ccicon.png b/android/app/src/main/res/mipmap-xhdpi/ccicon.png
new file mode 100644
index 0000000..f1d38cb
--- /dev/null
+++ b/android/app/src/main/res/mipmap-xhdpi/ccicon.png
Binary files differdiff --git a/android/app/src/main/res/mipmap-xxhdpi/ccicon.png b/android/app/src/main/res/mipmap-xxhdpi/ccicon.png
new file mode 100644
index 0000000..27a60f6
--- /dev/null
+++ b/android/app/src/main/res/mipmap-xxhdpi/ccicon.png
Binary files differdiff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..045e125
--- /dev/null
+++ b/android/app/src/main/res/values/strings.xml
@@ -0,0 +1,3 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+</resources>
diff --git a/android/build.gradle b/android/build.gradle
new file mode 100644
index 0000000..1f0e14f
--- /dev/null
+++ b/android/build.gradle
@@ -0,0 +1,21 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+buildscript {
+    repositories {
+       google()
+       jcenter()
+    }
+    dependencies {
+        classpath 'com.android.tools.build:gradle:3.6.4'
+    }
+}
+
+allprojects {
+    repositories {
+        google()
+        jcenter()
+    }
+}
+
+task clean(type: Delete) {
+    delete rootProject.buildDir
+}
diff --git a/android/gradle.properties b/android/gradle.properties
new file mode 100644
index 0000000..7bef3c2
--- /dev/null
+++ b/android/gradle.properties
@@ -0,0 +1,20 @@
+# Project-wide Gradle settings.
+
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+android.enableJetifier=true
+android.useAndroidX=true
+org.gradle.jvmargs=-Xmx1536m
+
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..8c0fb64
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.jar
Binary files differdiff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..5d30b62
--- /dev/null
+++ b/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,6 @@
+#Fri May 06 22:33:57 AEST 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.4-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
diff --git a/android/gradlew b/android/gradlew
new file mode 100644
index 0000000..91a7e26
--- /dev/null
+++ b/android/gradlew
@@ -0,0 +1,164 @@
+#!/usr/bin/env bash
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS=""
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD="maximum"
+
+warn ( ) {
+    echo "$*"
+}
+
+die ( ) {
+    echo
+    echo "$*"
+    echo
+    exit 1
+}
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+esac
+
+# For Cygwin, ensure paths are in UNIX format before anything is touched.
+if $cygwin ; then
+    [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"`
+fi
+
+# Attempt to set APP_HOME
+# Resolve links: $0 may be a link
+PRG="$0"
+# Need this for relative symlinks.
+while [ -h "$PRG" ] ; do
+    ls=`ls -ld "$PRG"`
+    link=`expr "$ls" : '.*-> \(.*\)$'`
+    if expr "$link" : '/.*' > /dev/null; then
+        PRG="$link"
+    else
+        PRG=`dirname "$PRG"`"/$link"
+    fi
+done
+SAVED="`pwd`"
+cd "`dirname \"$PRG\"`/" >&-
+APP_HOME="`pwd -P`"
+cd "$SAVED" >&-
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD="$JAVA_HOME/jre/sh/java"
+    else
+        JAVACMD="$JAVA_HOME/bin/java"
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD="java"
+    which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
+    MAX_FD_LIMIT=`ulimit -H -n`
+    if [ $? -eq 0 ] ; then
+        if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
+            MAX_FD="$MAX_FD_LIMIT"
+        fi
+        ulimit -n $MAX_FD
+        if [ $? -ne 0 ] ; then
+            warn "Could not set maximum file descriptor limit: $MAX_FD"
+        fi
+    else
+        warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
+    fi
+fi
+
+# For Darwin, add options to specify how the application appears in the dock
+if $darwin; then
+    GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
+fi
+
+# For Cygwin, switch paths to Windows format before running java
+if $cygwin ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+    # We build the pattern for arguments to be converted via cygpath
+    ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
+    SEP=""
+    for dir in $ROOTDIRSRAW ; do
+        ROOTDIRS="$ROOTDIRS$SEP$dir"
+        SEP="|"
+    done
+    OURCYGPATTERN="(^($ROOTDIRS))"
+    # Add a user-defined pattern to the cygpath arguments
+    if [ "$GRADLE_CYGPATTERN" != "" ] ; then
+        OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
+    fi
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    i=0
+    for arg in "$@" ; do
+        CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
+        CHECK2=`echo "$arg"|egrep -c "^-"`                                 ### Determine if an option
+
+        if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then                    ### Added a condition
+            eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
+        else
+            eval `echo args$i`="\"$arg\""
+        fi
+        i=$((i+1))
+    done
+    case $i in
+        (0) set -- ;;
+        (1) set -- "$args0" ;;
+        (2) set -- "$args0" "$args1" ;;
+        (3) set -- "$args0" "$args1" "$args2" ;;
+        (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
+        (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
+        (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
+        (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
+        (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
+        (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
+    esac
+fi
+
+# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
+function splitJvmOpts() {
+    JVM_OPTS=("$@")
+}
+eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
+JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
+
+exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/android/gradlew.bat b/android/gradlew.bat
new file mode 100644
index 0000000..8a0b282
--- /dev/null
+++ b/android/gradlew.bat
@@ -0,0 +1,90 @@
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS=
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto init
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto init
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:init
+@rem Get command-line arguments, handling Windowz variants
+
+if not "%OS%" == "Windows_NT" goto win9xME_args
+if "%@eval[2+2]" == "4" goto 4NT_args
+
+:win9xME_args
+@rem Slurp the command line arguments.
+set CMD_LINE_ARGS=
+set _SKIP=2
+
+:win9xME_args_slurp
+if "x%~1" == "x" goto execute
+
+set CMD_LINE_ARGS=%*
+goto execute
+
+:4NT_args
+@rem Get arguments from the 4NT Shell from JP Software
+set CMD_LINE_ARGS=%$
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if  not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/android/settings.gradle b/android/settings.gradle
new file mode 100644
index 0000000..573abcb
--- /dev/null
+++ b/android/settings.gradle
@@ -0,0 +1,2 @@
+include ':app'
+