Browse Source

add existing android files

Andrey Filipenkov 2 years ago
parent
commit
7d4f8ab70d
100 changed files with 11995 additions and 0 deletions
  1. 9 0
      android/.gitignore
  2. 9 0
      android/GeneratedVersion.java.in
  3. 21 0
      android/build.gradle
  4. 27 0
      android/defs.gradle
  5. 0 0
      android/defs.gradle.properties
  6. 19 0
      android/gradle.properties
  7. BIN
      android/gradle/wrapper/gradle-wrapper.jar
  8. 6 0
      android/gradle/wrapper/gradle-wrapper.properties
  9. 185 0
      android/gradlew
  10. 89 0
      android/gradlew.bat
  11. 9 0
      android/settings.gradle
  12. 1 0
      android/vcmi-app/.gitignore
  13. 214 0
      android/vcmi-app/build.gradle
  14. 21 0
      android/vcmi-app/proguard-rules.pro
  15. 53 0
      android/vcmi-app/src/main/AndroidManifest.xml
  16. 105 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityAbout.java
  17. 58 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityBase.java
  18. 48 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityError.java
  19. 303 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java
  20. 331 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityMods.java
  21. 53 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityWithToolbar.java
  22. 233 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/Config.java
  23. 24 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/Const.java
  24. 163 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java
  25. 107 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/ServerService.java
  26. 31 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/Storage.java
  27. 206 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java
  28. 172 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/AsyncLauncherInitialization.java
  29. 52 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/DialogAuthors.java
  30. 36 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModBaseViewHolder.java
  31. 254 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsAdapter.java
  32. 35 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsViewHolder.java
  33. 258 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIMod.java
  34. 106 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModContainer.java
  35. 108 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModsRepo.java
  36. 46 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiController.java
  37. 37 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiSelectionDialog.java
  38. 48 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingController.java
  39. 40 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingDialog.java
  40. 181 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CopyDataController.java
  41. 19 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/DoubleConfig.java
  42. 168 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ExportDataController.java
  43. 75 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingController.java
  44. 73 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingDialog.java
  45. 31 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithDialogController.java
  46. 83 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithSliderController.java
  47. 38 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ModsBtnController.java
  48. 40 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/MusicSettingController.java
  49. 75 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingController.java
  50. 58 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingDialog.java
  51. 51 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingController.java
  52. 48 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingDialog.java
  53. 66 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingController.java
  54. 104 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingDialog.java
  55. 40 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/SoundSettingController.java
  56. 39 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/StartGameController.java
  57. 49 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/AsyncRequest.java
  58. 344 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/FileUtil.java
  59. 8 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/IZipProgressReporter.java
  60. 198 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/InstallModAsync.java
  61. 100 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LegacyConfigReader.java
  62. 67 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LibsLoader.java
  63. 156 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Log.java
  64. 30 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/ServerResponse.java
  65. 92 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/SharedPrefs.java
  66. 58 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Utils.java
  67. 52 0
      android/vcmi-app/src/main/java/eu/vcmi/vcmi/viewmodels/ObservableViewModel.java
  68. 74 0
      android/vcmi-app/src/main/java/org/libsdl/app/DummyEdit.java
  69. 22 0
      android/vcmi-app/src/main/java/org/libsdl/app/HIDDevice.java
  70. 650 0
      android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java
  71. 679 0
      android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceManager.java
  72. 309 0
      android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceUSB.java
  73. 87 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDL.java
  74. 1931 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDLActivity.java
  75. 394 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDLAudioManager.java
  76. 788 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDLControllerManager.java
  77. 144 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDLInputConnection.java
  78. 405 0
      android/vcmi-app/src/main/java/org/libsdl/app/SDLSurface.java
  79. BIN
      android/vcmi-app/src/main/res/drawable-nodpi/divider_compat.png
  80. 30 0
      android/vcmi-app/src/main/res/drawable-v24/ic_launcher_foreground.xml
  81. 4 0
      android/vcmi-app/src/main/res/drawable/compat_toolbar_shadow.xml
  82. 9 0
      android/vcmi-app/src/main/res/drawable/ic_error.xml
  83. 9 0
      android/vcmi-app/src/main/res/drawable/ic_info.xml
  84. 170 0
      android/vcmi-app/src/main/res/drawable/ic_launcher_background.xml
  85. 9 0
      android/vcmi-app/src/main/res/drawable/ic_star_empty.xml
  86. 9 0
      android/vcmi-app/src/main/res/drawable/ic_star_full.xml
  87. 9 0
      android/vcmi-app/src/main/res/drawable/ic_star_half.xml
  88. 6 0
      android/vcmi-app/src/main/res/drawable/overlay_edittext_background.xml
  89. 7 0
      android/vcmi-app/src/main/res/drawable/recycler_divider_drawable.xml
  90. 19 0
      android/vcmi-app/src/main/res/layout-v21/inc_toolbar.xml
  91. 76 0
      android/vcmi-app/src/main/res/layout/activity_about.xml
  92. 22 0
      android/vcmi-app/src/main/res/layout/activity_error.xml
  93. 19 0
      android/vcmi-app/src/main/res/layout/activity_game.xml
  94. 106 0
      android/vcmi-app/src/main/res/layout/activity_launcher.xml
  95. 29 0
      android/vcmi-app/src/main/res/layout/activity_mods.xml
  96. 18 0
      android/vcmi-app/src/main/res/layout/activity_toolbar_wrapper.xml
  97. 34 0
      android/vcmi-app/src/main/res/layout/dialog_authors.xml
  98. 33 0
      android/vcmi-app/src/main/res/layout/inc_launcher_btn.xml
  99. 27 0
      android/vcmi-app/src/main/res/layout/inc_launcher_slider.xml
  100. 7 0
      android/vcmi-app/src/main/res/layout/inc_separator.xml

+ 9 - 0
android/.gitignore

@@ -0,0 +1,9 @@
+*.iml
+.gradle
+/local.properties
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties

+ 9 - 0
android/GeneratedVersion.java.in

@@ -0,0 +1,9 @@
+package eu.vcmi.vcmi.util;
+
+/**
+ * Generated via cmake (./project/vcmi-app/cmake-scripts/versions.cmake)
+ */
+public class GeneratedVersion
+{
+    public static final String VCMI_VERSION = "@VCMI_VERSION@";
+}

+ 21 - 0
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()
+		mavenCentral()
+	}
+	dependencies {
+		classpath 'com.android.tools.build:gradle:7.3.0'
+
+		// NOTE: Do not place your application dependencies here; they belong
+		// in the individual module build.gradle files
+	}
+}
+
+allprojects {
+	apply from: rootProject.file("defs.gradle")
+}
+
+task clean(type: Delete) {
+	delete rootProject.buildDir
+}

+ 27 - 0
android/defs.gradle

@@ -0,0 +1,27 @@
+import groovy.json.JsonSlurper
+
+ext {
+	final def jsonFile = rootProject.file("../vcmiconf.json")
+	final def rawConf = new JsonSlurper().parseText(jsonFile.text)
+
+	PROJECT_PATH_BASE = jsonFile.getParentFile().getAbsolutePath().replace('\\', '/')
+	VCMI_PATH_EXT = "${PROJECT_PATH_BASE}/ext"
+	VCMI_PATH_MAIN = "${PROJECT_PATH_BASE}/project"
+
+	VCMI_PATH_VCMI = "${VCMI_PATH_EXT}/vcmi"
+
+	// can be 16 if building only for armeabi-v7a, definitely needs to be 21+ to build arm64 and x86_64
+	VCMI_PLATFORM = rawConf.androidApi
+	// we should be able to use the newest version to compile, but it seems that gradle-experimental is somehow broken and doesn't compile native libs correctly for apis older than this setting...
+	VCMI_COMPILE_SDK = 26
+	VCMI_ABIS = rawConf.abis.split(" ")
+
+	VCMI_STL_VERSION = "c++_shared"
+	VCMI_BUILD_TOOLS = "25.0.2"
+
+	// these values will be retrieved during gradle build
+	gitInfoLauncher = "none"
+	gitInfoVcmi = "none"
+
+	//logger.info("Base path = ${PROJECT_PATH_BASE}")
+}

+ 0 - 0
android/defs.gradle.properties


+ 19 - 0
android/gradle.properties

@@ -0,0 +1,19 @@
+# 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.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# 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
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app"s APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Automatically convert third-party libraries to use AndroidX
+android.enableJetifier=true

BIN
android/gradle/wrapper/gradle-wrapper.jar


+ 6 - 0
android/gradle/wrapper/gradle-wrapper.properties

@@ -0,0 +1,6 @@
+#Tue Sep 27 21:00:27 EEST 2022
+distributionBase=GRADLE_USER_HOME
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip
+distributionPath=wrapper/dists
+zipStorePath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME

+ 185 - 0
android/gradlew

@@ -0,0 +1,185 @@
+#!/usr/bin/env sh
+
+#
+# Copyright 2015 the original author or authors.
+#
+# 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
+#
+#      https://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.
+#
+
+##############################################################################
+##
+##  Gradle start up script for UN*X
+##
+##############################################################################
+
+# 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\"`/" >/dev/null
+APP_HOME="`pwd -P`"
+cd "$SAVED" >/dev/null
+
+APP_NAME="Gradle"
+APP_BASE_NAME=`basename "$0"`
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# 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
+nonstop=false
+case "`uname`" in
+  CYGWIN* )
+    cygwin=true
+    ;;
+  Darwin* )
+    darwin=true
+    ;;
+  MINGW* )
+    msys=true
+    ;;
+  NONSTOP* )
+    nonstop=true
+    ;;
+esac
+
+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" -a "$nonstop" = "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 or MSYS, switch paths to Windows format before running java
+if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
+    APP_HOME=`cygpath --path --mixed "$APP_HOME"`
+    CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
+
+    JAVACMD=`cygpath --unix "$JAVACMD"`
+
+    # 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=`expr $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
+
+# Escape application args
+save () {
+    for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
+    echo " "
+}
+APP_ARGS=`save "$@"`
+
+# Collect all arguments for the java command, following the shell quoting and substitution rules
+eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
+
+exec "$JAVACMD" "$@"

+ 89 - 0
android/gradlew.bat

@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@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
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@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="-Xmx64m" "-Xms64m"
+
+@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 execute
+
+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 execute
+
+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
+
+: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 %*
+
+: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

+ 9 - 0
android/settings.gradle

@@ -0,0 +1,9 @@
+dependencyResolutionManagement {
+    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+    repositories {
+        google()
+        mavenCentral()
+    }
+}
+rootProject.name = "VCMI"
+include ':vcmi-app'

+ 1 - 0
android/vcmi-app/.gitignore

@@ -0,0 +1 @@
+/build

+ 214 - 0
android/vcmi-app/build.gradle

@@ -0,0 +1,214 @@
+plugins {
+	id 'com.android.application'
+}
+
+android {
+	compileSdk 31
+
+	defaultConfig {
+		applicationId "is.xyz.vcmi"
+		minSdk 19
+		targetSdk 31
+		versionCode 1103
+		versionName "1.1"
+		setProperty("archivesBaseName", "vcmi")
+
+		externalNativeBuild {
+			cmake {
+				version "3.18+"
+				arguments "-DANDROID_STL=${VCMI_STL_VERSION}",
+					"-DANDROID_NATIVE_API_LEVEL=${VCMI_PLATFORM}",
+					"-DANDROID_TOOLCHAIN=clang",
+					"-DVCMI_ROOT=${PROJECT_PATH_BASE}"
+				cppFlags "-frtti", "-fexceptions", "-Wno-switch"
+			}
+		}
+		ndk {
+			abiFilters = new HashSet<>()
+			abiFilters.addAll(VCMI_ABIS)
+		}
+	}
+
+	signingConfigs {
+		releaseSigning
+		LoadSigningConfig(PROJECT_PATH_BASE)
+	}
+
+	sourceSets {
+		main {
+			jniLibs.srcDirs = ["${PROJECT_PATH_BASE}/ext-output"]
+		}
+	}
+
+	buildTypes {
+		release {
+			minifyEnabled false
+			zipAlignEnabled true
+			signingConfig signingConfigs.releaseSigning
+			proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+		}
+	}
+
+	applicationVariants.all { variant -> RenameOutput(project.archivesBaseName, variant) }
+
+	tasks.withType(JavaCompile) {
+		options.compilerArgs += ["-Xlint:deprecation"]
+	}
+
+	compileOptions {
+		sourceCompatibility JavaVersion.VERSION_1_8
+		targetCompatibility JavaVersion.VERSION_1_8
+	}
+
+	externalNativeBuild {
+		cmake {
+			version "3.18.0+"
+			path file('cmake-scripts/CMakeLists.txt')
+		}
+	}
+
+	buildFeatures {
+		viewBinding true
+		dataBinding true
+	}
+
+	flavorDimensions "vcmi"
+	productFlavors {
+		VcmiOnly {
+			dimension "vcmi"
+			externalNativeBuild {
+				cmake {
+					version "3.18+"
+					targets "vcmi",
+							"vcmiserver",
+							"vcmiclient"
+				}
+			}
+		}
+		LibsOnly {
+			dimension "vcmi"
+			externalNativeBuild {
+				cmake {
+					version "3.18+"
+					targets "boost-datetime",
+						"boost-system",
+						"boost-filesystem",
+						"boost-locale",
+						"boost-program-options",
+						"boost-thread",
+						"fl-shared",
+						"minizip"
+				}
+			}
+		}
+		AllTargets {
+			dimension "vcmi"
+			externalNativeBuild {
+				cmake {
+					version "3.18+"
+					targets "boost-datetime",
+						"boost-system",
+						"boost-filesystem",
+						"boost-locale",
+						"boost-program-options",
+						"boost-thread",
+						"fl-shared",
+						"minizip",
+						"vcmi",
+						"vcmiserver",
+						"vcmiclient"
+				}
+			}
+		}
+	}
+}
+
+def RenameOutput(final baseName, final variant) {
+	final def buildTaskId = System.getenv("GITHUB_RUN_ID")
+
+	ResolveGitInfo()
+
+	def name = baseName + "-" + ext.gitInfoLauncher + "-" + ext.gitInfoVcmi
+
+	if (buildTaskId != null && !buildTaskId.isEmpty()) {
+		name = buildTaskId + "-" + name
+	}
+
+	if (!variant.buildType.name != "release") {
+		name += "-" + variant.buildType.name
+	}
+
+	variant.outputs.each { output ->
+		def oldPath = output.outputFile.getAbsolutePath()
+		output.outputFileName = name + oldPath.substring(oldPath.lastIndexOf("."))
+	}
+}
+
+def CommandOutput(final cmd, final arguments, final cwd) {
+	try {
+		new ByteArrayOutputStream().withStream { final os ->
+			exec {
+				executable cmd
+				args arguments
+				workingDir cwd
+				standardOutput os
+			}
+			return os.toString().trim()
+		}
+	}
+	catch (final Exception ex) {
+		print("Broken: " + cmd + " " + arguments + " in " + cwd + " :: " + ex.toString())
+		return ""
+	}
+}
+
+def ResolveGitInfo() {
+	if (ext.gitInfoLauncher != "none" && ext.gitInfoVcmi != "none") {
+		return
+	}
+	ext.gitInfoLauncher = CommandOutput("git", ["describe", "--match=", "--always", "--abbrev=7"], PROJECT_PATH_BASE)
+	ext.gitInfoVcmi =
+		CommandOutput("git", ["log", "-1", "--pretty=%D", "--decorate-refs=refs/remotes/origin/*"], PROJECT_PATH_BASE + "/ext/vcmi").replace("origin/", "").replace(", HEAD", "").replaceAll("[^a-zA-Z0-9\\-_]", "_") +
+		"-" +
+		CommandOutput("git", ["describe", "--match=", "--always", "--abbrev=7"], PROJECT_PATH_BASE + "/ext/vcmi")
+}
+
+def SigningPropertiesPath(final basePath) {
+	return file(basePath + "/.github/CI/signing.properties")
+}
+
+def SigningKeystorePath(final basePath, final keystoreFileName) {
+	return file(basePath + "/.github/CI/" + keystoreFileName)
+}
+
+def LoadSigningConfig(final basePath) {
+	final def props = new Properties()
+	final def propFile = SigningPropertiesPath(basePath)
+	if (propFile.canRead()) {
+		props.load(new FileInputStream(propFile))
+
+		if (props != null
+			&& props.containsKey('STORE_FILE')
+			&& props.containsKey('STORE_PASSWORD')
+			&& props.containsKey('KEY_ALIAS')
+			&& props.containsKey('KEY_PASSWORD')) {
+
+			android.signingConfigs.releaseSigning.storeFile = SigningKeystorePath(basePath, props['STORE_FILE'])
+			android.signingConfigs.releaseSigning.storePassword = props['STORE_PASSWORD']
+			android.signingConfigs.releaseSigning.keyAlias = props['KEY_ALIAS']
+			android.signingConfigs.releaseSigning.keyPassword = props['KEY_PASSWORD']
+		} else {
+			println("Some props from signing file are missing")
+			android.buildTypes.release.signingConfig = null
+		}
+	} else {
+		println("file with signing properties is missing")
+		android.buildTypes.release.signingConfig = null
+	}
+}
+
+dependencies {
+	implementation 'androidx.appcompat:appcompat:1.2.0'
+	implementation 'com.google.android.material:material:1.3.0'
+	implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
+}

+ 21 - 0
android/vcmi-app/proguard-rules.pro

@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+#   http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+#   public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile

+ 53 - 0
android/vcmi-app/src/main/AndroidManifest.xml

@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="eu.vcmi.vcmi">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+
+    <application
+        android:extractNativeLibs="true"
+        android:hardwareAccelerated="true"
+        android:hasFragileUserData="true"
+        android:allowBackup="false"
+        android:installLocation="auto"
+        android:icon="@mipmap/ic_launcher"
+        android:label="@string/app_name"
+        android:testOnly="false"
+        android:supportsRtl="true"
+        android:theme="@style/Theme.VCMI">
+        <activity
+            android:exported="true"
+            android:name=".ActivityLauncher"
+            android:screenOrientation="sensorLandscape">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+        <activity
+            android:name=".ActivityError"
+            android:screenOrientation="sensorLandscape" />
+        <activity
+            android:name=".ActivityMods"
+            android:screenOrientation="sensorLandscape" />
+        <activity
+            android:name=".ActivityAbout"
+            android:screenOrientation="sensorLandscape" />
+
+        <activity
+            android:name=".VcmiSDLActivity"
+            android:configChanges="keyboardHidden|orientation|screenSize"
+            android:label="@string/app_name"
+            android:launchMode="singleTop"
+            android:screenOrientation="sensorLandscape"
+            android:theme="@style/Theme.VCMI.Full" />
+
+        <service
+            android:name=".ServerService"
+            android:process="eu.vcmi.vcmi.srv"
+            android:description="@string/server_name"
+            android:exported="false"/>
+    </application>
+
+</manifest>

+ 105 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityAbout.java

@@ -0,0 +1,105 @@
+package eu.vcmi.vcmi;
+
+import android.content.ActivityNotFoundException;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import android.text.SpannableString;
+import android.text.Spanned;
+import android.text.style.ForegroundColorSpan;
+import android.text.style.UnderlineSpan;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import eu.vcmi.vcmi.content.DialogAuthors;
+import eu.vcmi.vcmi.util.GeneratedVersion;
+import eu.vcmi.vcmi.util.Utils;
+
+/**
+ * @author F
+ */
+public class ActivityAbout extends ActivityWithToolbar
+{
+    private static final String DIALOG_AUTHORS_TAG = "DIALOG_AUTHORS_TAG";
+
+    @Override
+    protected void onCreate(@Nullable final Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_about);
+        initToolbar(R.string.about_title);
+
+        initControl(R.id.about_version_app, getString(R.string.about_version_app, GeneratedVersion.VCMI_VERSION));
+        initControl(R.id.about_version_launcher, getString(R.string.about_version_launcher, Utils.appVersionName(this)));
+        initControlUrl(R.id.about_link_portal, R.string.about_links_main, R.string.url_project_page, this::onUrlPressed);
+        initControlUrl(R.id.about_link_repo_main, R.string.about_links_repo, R.string.url_project_repo, this::onUrlPressed);
+        initControlUrl(R.id.about_link_repo_launcher, R.string.about_links_repo_launcher, R.string.url_launcher_repo, this::onUrlPressed);
+        initControlBtn(R.id.about_btn_authors, this::onBtnAuthorsPressed);
+        initControlBtn(R.id.about_btn_libs, this::onBtnLibsPressed);
+        initControlUrl(R.id.about_btn_privacy, R.string.about_btn_privacy, R.string.url_launcher_privacy, this::onUrlPressed);
+    }
+
+    private void initControlBtn(final int viewId, final View.OnClickListener callback)
+    {
+        findViewById(viewId).setOnClickListener(callback);
+    }
+
+    private void initControlUrl(final int textViewResId, final int baseTextRes, final int urlTextRes, final IInternalUrlCallback callback)
+    {
+        final TextView ctrl = (TextView) findViewById(textViewResId);
+        final String urlText = getString(urlTextRes);
+        final String fullText = getString(baseTextRes, urlText);
+
+        ctrl.setText(decoratedLinkText(fullText, fullText.indexOf(urlText), fullText.length()));
+        ctrl.setOnClickListener(v -> callback.onPressed(urlText));
+    }
+
+    private Spanned decoratedLinkText(final String rawText, final int start, final int end)
+    {
+        final SpannableString spannableString = new SpannableString(rawText);
+        spannableString.setSpan(new UnderlineSpan(), start, end, 0);
+        spannableString.setSpan(new ForegroundColorSpan(ContextCompat.getColor(this, R.color.accent)), start, end, 0);
+        return spannableString;
+    }
+
+    private void initControl(final int textViewResId, final String text)
+    {
+        ((TextView) findViewById(textViewResId)).setText(text);
+    }
+
+    private void onBtnAuthorsPressed(final View v)
+    {
+        final DialogAuthors dialogAuthors = new DialogAuthors();
+        dialogAuthors.show(getSupportFragmentManager(), DIALOG_AUTHORS_TAG);
+    }
+
+    private void onBtnLibsPressed(final View v)
+    {
+        // TODO 3rd party libs view (dialog?)
+    }
+
+    private void onBtnPrivacyPressed(final View v)
+    {
+        // TODO tbd if we even need this in app
+    }
+
+    private void onUrlPressed(final String url)
+    {
+        try
+        {
+            startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url)));
+        }
+        catch (final ActivityNotFoundException ignored)
+        {
+            Toast.makeText(this, R.string.about_error_opening_url, Toast.LENGTH_LONG).show();
+        }
+    }
+
+    private interface IInternalUrlCallback
+    {
+        void onPressed(final String link);
+    }
+}

+ 58 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityBase.java

@@ -0,0 +1,58 @@
+package eu.vcmi.vcmi;
+
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.util.Log;
+import eu.vcmi.vcmi.util.SharedPrefs;
+
+/**
+ * @author F
+ */
+public abstract class ActivityBase extends AppCompatActivity
+{
+    protected SharedPrefs mPrefs;
+
+    @Override
+    protected void onCreate(@Nullable final Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+        setupExceptionHandler();
+        mPrefs = new SharedPrefs(this);
+    }
+
+    private void setupExceptionHandler()
+    {
+        final Thread.UncaughtExceptionHandler prevHandler = Thread.getDefaultUncaughtExceptionHandler();
+        if (prevHandler != null && !(prevHandler instanceof VCMIExceptionHandler)) // no need to recreate it if it's already setup
+        {
+            Thread.setDefaultUncaughtExceptionHandler(new VCMIExceptionHandler(prevHandler));
+        }
+    }
+
+    private static class VCMIExceptionHandler implements Thread.UncaughtExceptionHandler
+    {
+        private Thread.UncaughtExceptionHandler mPrevHandler;
+
+        private VCMIExceptionHandler(final Thread.UncaughtExceptionHandler prevHandler)
+        {
+            mPrevHandler = prevHandler;
+        }
+
+        @Override
+        public void uncaughtException(final Thread thread, final Throwable throwable)
+        {
+            Log.e(this, "Unhandled exception", throwable); // to save the exception to file before crashing
+
+            if (mPrevHandler != null && !(mPrevHandler instanceof VCMIExceptionHandler))
+            {
+                mPrevHandler.uncaughtException(thread, throwable);
+            }
+            else
+            {
+                System.exit(1);
+            }
+        }
+    }
+}

+ 48 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityError.java

@@ -0,0 +1,48 @@
+package eu.vcmi.vcmi;
+
+import android.content.Intent;
+import android.os.Bundle;
+import androidx.annotation.Nullable;
+import android.view.View;
+import android.widget.TextView;
+
+/**
+ * @author F
+ */
+public class ActivityError extends ActivityWithToolbar
+{
+    public static final String ARG_ERROR_MSG = "ActivityError.msg";
+
+    @Override
+    protected void onCreate(@Nullable final Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_error);
+        initToolbar(R.string.launcher_title);
+
+        final View btnTryAgain = findViewById(R.id.error_btn_try_again);
+        btnTryAgain.setOnClickListener(new OnErrorRetryPressed());
+
+        final Bundle extras = getIntent().getExtras();
+        if (extras != null)
+        {
+            final String errorMessage = extras.getString(ARG_ERROR_MSG);
+            final TextView errorMessageView = (TextView) findViewById(R.id.error_message);
+            if (errorMessage != null)
+            {
+                errorMessageView.setText(errorMessage);
+            }
+        }
+    }
+
+    private class OnErrorRetryPressed implements View.OnClickListener
+    {
+        @Override
+        public void onClick(final View v)
+        {
+            // basically restarts main activity
+            startActivity(new Intent(ActivityError.this, ActivityLauncher.class));
+            finish();
+        }
+    }
+}

+ 303 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityLauncher.java

@@ -0,0 +1,303 @@
+package eu.vcmi.vcmi;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.TextView;
+import android.widget.Toast;
+import org.json.JSONObject;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.content.AsyncLauncherInitialization;
+import eu.vcmi.vcmi.settings.AdventureAiController;
+import eu.vcmi.vcmi.settings.CodepageSettingController;
+import eu.vcmi.vcmi.settings.CopyDataController;
+import eu.vcmi.vcmi.settings.ExportDataController;
+import eu.vcmi.vcmi.settings.LauncherSettingController;
+import eu.vcmi.vcmi.settings.ModsBtnController;
+import eu.vcmi.vcmi.settings.MusicSettingController;
+import eu.vcmi.vcmi.settings.PointerModeSettingController;
+import eu.vcmi.vcmi.settings.PointerMultiplierSettingController;
+import eu.vcmi.vcmi.settings.ScreenResSettingController;
+import eu.vcmi.vcmi.settings.SoundSettingController;
+import eu.vcmi.vcmi.settings.StartGameController;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+import eu.vcmi.vcmi.util.SharedPrefs;
+
+/**
+ * @author F
+ */
+public class ActivityLauncher extends ActivityWithToolbar
+{
+    public static final int PERMISSIONS_REQ_CODE = 123;
+
+    private final List<LauncherSettingController<?, ?>> mActualSettings = new ArrayList<>();
+    private View mProgress;
+    private TextView mErrorMessage;
+    private Config mConfig;
+    private LauncherSettingController<ScreenResSettingController.ScreenRes, Config> mCtrlScreenRes;
+    private LauncherSettingController<String, Config> mCtrlCodepage;
+    private LauncherSettingController<PointerModeSettingController.PointerMode, Config> mCtrlPointerMode;
+    private LauncherSettingController<Void, Void> mCtrlStart;
+    private LauncherSettingController<Float, Config> mCtrlPointerMulti;
+    private LauncherSettingController<Integer, Config> mCtrlSoundVol;
+    private LauncherSettingController<Integer, Config> mCtrlMusicVol;
+    private LauncherSettingController<String, Config> mAiController;
+    private CopyDataController mCtrlCopy;
+    private ExportDataController mCtrlExport;
+
+    private final AsyncLauncherInitialization.ILauncherCallbacks mInitCallbacks = new AsyncLauncherInitialization.ILauncherCallbacks()
+    {
+        @Override
+        public Activity ctx()
+        {
+            return ActivityLauncher.this;
+        }
+
+        @Override
+        public SharedPrefs prefs()
+        {
+            return mPrefs;
+        }
+
+        @Override
+        public void onInitSuccess()
+        {
+            loadConfigFile();
+            mCtrlStart.show();
+            mCtrlCopy.show();
+            mCtrlExport.show();
+            for (LauncherSettingController<?, ?> setting: mActualSettings) {
+                setting.show();
+            }
+            mErrorMessage.setVisibility(View.GONE);
+            mProgress.setVisibility(View.GONE);
+        }
+
+        @Override
+        public void onInitFailure(final AsyncLauncherInitialization.InitResult result)
+        {
+            mCtrlCopy.show();
+            if (result.mFailSilently)
+            {
+                return;
+            }
+            ActivityLauncher.this.onInitFailure(result);
+        }
+    };
+
+    @Override
+    public void onCreate(final Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+
+        if (savedInstanceState == null) // only clear the log if this is initial onCreate and not config change
+        {
+            Log.init();
+        }
+
+        Log.i(this, "Starting launcher");
+
+        setContentView(R.layout.activity_launcher);
+        initToolbar(R.string.launcher_title, true);
+
+        mProgress = findViewById(R.id.launcher_progress);
+        mErrorMessage = (TextView) findViewById(R.id.launcher_error);
+        mErrorMessage.setVisibility(View.GONE);
+
+        ((TextView) findViewById(R.id.launcher_version_info)).setText(getString(R.string.launcher_version, BuildConfig.VERSION_NAME));
+
+        initSettingsGui();
+    }
+
+    @Override
+    public void onStart()
+    {
+        super.onStart();
+        new AsyncLauncherInitialization(mInitCallbacks).execute((Void) null);
+    }
+
+    @Override
+    public void onBackPressed()
+    {
+        saveConfig();
+        super.onBackPressed();
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(final Menu menu)
+    {
+        getMenuInflater().inflate(R.menu.menu_launcher, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item)
+    {
+        if (item.getItemId() == R.id.menu_launcher_about)
+        {
+            startActivity(new Intent(this, ActivityAbout.class));
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent resultData)
+    {
+        if(requestCode == CopyDataController.PICK_EXTERNAL_VCMI_DATA_TO_COPY
+            && resultCode == Activity.RESULT_OK)
+        {
+            Uri uri;
+
+            if (resultData != null)
+            {
+                uri = resultData.getData();
+
+                mCtrlCopy.copyData(uri);
+            }
+
+            return;
+        }
+
+        if(requestCode == ExportDataController.PICK_DIRECTORY_TO_EXPORT
+                && resultCode == Activity.RESULT_OK)
+        {
+            Uri uri = null;
+            if (resultData != null)
+            {
+                uri = resultData.getData();
+
+                mCtrlExport.copyData(uri);
+            }
+
+            return;
+        }
+
+        super.onActivityResult(requestCode, resultCode, resultData);
+    }
+
+    public void requestStoragePermissions()
+    {
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
+        {
+            requestPermissions(
+                    new String[] {Manifest.permission.WRITE_EXTERNAL_STORAGE},
+                    PERMISSIONS_REQ_CODE);
+        }
+    }
+
+    private void initSettingsGui()
+    {
+        mCtrlStart = new StartGameController(this, v -> onLaunchGameBtnPressed()).init(R.id.launcher_btn_start);
+        (mCtrlCopy = new CopyDataController(this)).init(R.id.launcher_btn_copy);
+        (mCtrlExport = new ExportDataController(this)).init(R.id.launcher_btn_export);
+        new ModsBtnController(this, v -> startActivity(new Intent(ActivityLauncher.this, ActivityMods.class))).init(R.id.launcher_btn_mods);
+        mCtrlScreenRes = new ScreenResSettingController(this).init(R.id.launcher_btn_res, mConfig);
+        mCtrlCodepage = new CodepageSettingController(this).init(R.id.launcher_btn_cp, mConfig);
+        mCtrlPointerMode = new PointerModeSettingController(this).init(R.id.launcher_btn_pointer_mode, mConfig);
+        mCtrlPointerMulti = new PointerMultiplierSettingController(this).init(R.id.launcher_btn_pointer_multi, mConfig);
+        mCtrlSoundVol = new SoundSettingController(this).init(R.id.launcher_btn_volume_sound, mConfig);
+        mCtrlMusicVol = new MusicSettingController(this).init(R.id.launcher_btn_volume_music, mConfig);
+        mAiController = new AdventureAiController(this).init(R.id.launcher_btn_adventure_ai, mConfig);
+
+        mActualSettings.clear();
+        mActualSettings.add(mCtrlCodepage);
+        mActualSettings.add(mCtrlScreenRes);
+        mActualSettings.add(mCtrlPointerMode);
+        mActualSettings.add(mCtrlPointerMulti);
+        mActualSettings.add(mCtrlSoundVol);
+        mActualSettings.add(mCtrlMusicVol);
+        mActualSettings.add(mAiController);
+
+        mCtrlStart.hide(); // start is initially hidden, until we confirm that everything is okay via AsyncLauncherInitialization
+        mCtrlCopy.hide();
+        mCtrlExport.hide();
+    }
+
+    private void onLaunchGameBtnPressed()
+    {
+        saveConfig();
+        startActivity(new Intent(ActivityLauncher.this, VcmiSDLActivity.class));
+    }
+
+    private void saveConfig()
+    {
+        if (mConfig == null)
+        {
+            return;
+        }
+
+        try
+        {
+            mConfig.save(new File(FileUtil.configFileLocation(Storage.getVcmiDataDir(this))));
+        }
+        catch (final Exception e)
+        {
+            Toast.makeText(this, getString(R.string.launcher_error_config_saving_failed, e.getMessage()), Toast.LENGTH_LONG).show();
+        }
+    }
+
+    private void loadConfigFile()
+    {
+        try
+        {
+            final String settingsFileContent = FileUtil.read(
+                    new File(FileUtil.configFileLocation(Storage.getVcmiDataDir(this))));
+
+            mConfig = Config.load(new JSONObject(settingsFileContent));
+        }
+        catch (final Exception e)
+        {
+            Log.e(this, "Could not load config file", e);
+            mConfig = new Config();
+        }
+        onConfigUpdated();
+    }
+
+    private void onConfigUpdated()
+    {
+        updateCtrlConfig(mCtrlScreenRes, mConfig);
+        updateCtrlConfig(mCtrlCodepage, mConfig);
+        updateCtrlConfig(mCtrlPointerMode, mConfig);
+        updateCtrlConfig(mCtrlPointerMulti, mConfig);
+        updateCtrlConfig(mCtrlSoundVol, mConfig);
+        updateCtrlConfig(mCtrlMusicVol, mConfig);
+        updateCtrlConfig(mAiController, mConfig);
+    }
+
+    private <TSetting, TConf> void updateCtrlConfig(
+            final LauncherSettingController<TSetting, TConf> ctrl,
+            final TConf config)
+    {
+        if (ctrl != null)
+        {
+            ctrl.updateConfig(config);
+        }
+    }
+
+    private void onInitFailure(final AsyncLauncherInitialization.InitResult initResult)
+    {
+        Log.d(this, "Init failed with " + initResult);
+
+        mProgress.setVisibility(View.GONE);
+        mCtrlStart.hide();
+
+        for (LauncherSettingController<?, ?> setting: mActualSettings)
+        {
+            setting.hide();
+        }
+
+        mErrorMessage.setVisibility(View.VISIBLE);
+        mErrorMessage.setText(initResult.mMessage);
+    }
+}

+ 331 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityMods.java

@@ -0,0 +1,331 @@
+package eu.vcmi.vcmi;
+
+import android.content.DialogInterface;
+import android.os.AsyncTask;
+import android.os.Bundle;
+
+import androidx.annotation.Nullable;
+import com.google.android.material.snackbar.Snackbar;
+
+import androidx.appcompat.app.AlertDialog;
+import androidx.recyclerview.widget.DefaultItemAnimator;
+import androidx.recyclerview.widget.DividerItemDecoration;
+import androidx.recyclerview.widget.LinearLayoutManager;
+import androidx.recyclerview.widget.RecyclerView;
+
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.widget.TextView;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+import eu.vcmi.vcmi.content.ModBaseViewHolder;
+import eu.vcmi.vcmi.content.ModsAdapter;
+import eu.vcmi.vcmi.mods.VCMIMod;
+import eu.vcmi.vcmi.mods.VCMIModContainer;
+import eu.vcmi.vcmi.mods.VCMIModsRepo;
+import eu.vcmi.vcmi.util.InstallModAsync;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+import eu.vcmi.vcmi.util.ServerResponse;
+
+/**
+ * @author F
+ */
+public class ActivityMods extends ActivityWithToolbar
+{
+    private static final boolean ENABLE_REPO_DOWNLOADING = true;
+    private static final String REPO_URL = "https://raw.githubusercontent.com/vcmi/vcmi-mods-repository/develop/github.json";
+    private VCMIModsRepo mRepo;
+    private RecyclerView mRecycler;
+
+    private VCMIModContainer mModContainer;
+    private TextView mErrorMessage;
+    private View mProgress;
+    private ModsAdapter mModsAdapter;
+
+    @Override
+    protected void onCreate(@Nullable final Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_mods);
+        initToolbar(R.string.mods_title);
+
+        mRepo = new VCMIModsRepo();
+
+        mProgress = findViewById(R.id.mods_progress);
+
+        mErrorMessage = (TextView) findViewById(R.id.mods_error_text);
+        mErrorMessage.setVisibility(View.GONE);
+
+        mRecycler = (RecyclerView) findViewById(R.id.mods_recycler);
+        mRecycler.setItemAnimator(new DefaultItemAnimator());
+        mRecycler.setLayoutManager(new LinearLayoutManager(this));
+        mRecycler.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));
+        mRecycler.setVisibility(View.GONE);
+
+        mModsAdapter = new ModsAdapter(new OnAdapterItemAction());
+        mRecycler.setAdapter(mModsAdapter);
+
+        new AsyncLoadLocalMods().execute((Void) null);
+    }
+
+    private void loadLocalModData() throws IOException, JSONException
+    {
+        final File dataRoot = Storage.getVcmiDataDir(this);
+        final String internalDataRoot = getFilesDir() + "/" + Const.VCMI_DATA_ROOT_FOLDER_NAME;
+
+        final File modsRoot = new File(dataRoot,"/Mods");
+        final File internalModsRoot = new File(internalDataRoot + "/Mods");
+        if (!modsRoot.exists() && !internalModsRoot.exists())
+        {
+            Log.w(this, "We don't have mods folders");
+            return;
+        }
+        final File[] modsFiles = modsRoot.listFiles();
+        final File[] internalModsFiles = internalModsRoot.listFiles();
+        final List<File> topLevelModsFolders = new ArrayList<>();
+        if (modsFiles != null && modsFiles.length > 0)
+        {
+            Collections.addAll(topLevelModsFolders, modsFiles);
+        }
+        if (internalModsFiles != null && internalModsFiles.length > 0)
+        {
+            Collections.addAll(topLevelModsFolders, internalModsFiles);
+        }
+        mModContainer = VCMIModContainer.createContainer(topLevelModsFolders);
+
+        final File modConfigFile = new File(dataRoot, "config/modSettings.json");
+        if (!modConfigFile.exists())
+        {
+            Log.w(this, "We don't have mods config");
+            return;
+        }
+
+        JSONObject rootConfigObj = new JSONObject(FileUtil.read(modConfigFile));
+        JSONObject activeMods = rootConfigObj.getJSONObject("activeMods");
+        mModContainer.updateContainerFromConfigJson(activeMods, rootConfigObj.optJSONObject("core"));
+
+        Log.i(this, "Loaded mods: " + mModContainer);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(final Menu menu)
+    {
+        final MenuInflater menuInflater = getMenuInflater();
+        menuInflater.inflate(R.menu.menu_mods, menu);
+        return super.onCreateOptionsMenu(menu);
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item)
+    {
+        if (item.getItemId() == R.id.menu_mods_download_repo)
+        {
+            Log.i(this, "Should download repo now...");
+            if (ENABLE_REPO_DOWNLOADING)
+            {
+                mProgress.setVisibility(View.VISIBLE);
+                mRepo.init(REPO_URL, new OnModsRepoInitialized()); // disabled because the json is broken anyway
+            }
+            else
+            {
+                Snackbar.make(findViewById(R.id.mods_data_root), "Loading repo is disabled for now, because .json can't be parsed anyway",
+                    Snackbar.LENGTH_LONG).show();
+            }
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    private void handleNoData()
+    {
+        mProgress.setVisibility(View.GONE);
+        mRecycler.setVisibility(View.GONE);
+        mErrorMessage.setVisibility(View.VISIBLE);
+        mErrorMessage.setText("Could not load local mods list");
+    }
+
+    private void saveModSettingsToFile()
+    {
+        mModContainer.saveToFile(
+                new File(
+                        Storage.getVcmiDataDir(this),
+                        "config/modSettings.json"));
+    }
+
+    private class OnModsRepoInitialized implements VCMIModsRepo.IOnModsRepoDownloaded
+    {
+        @Override
+        public void onSuccess(ServerResponse<List<VCMIMod>> response)
+        {
+            Log.i(this, "Initialized mods repo");
+            mModContainer.updateFromRepo(response.mContent);
+            mModsAdapter.updateModsList(mModContainer.submods());
+            mProgress.setVisibility(View.GONE);
+        }
+
+        @Override
+        public void onError(final int code)
+        {
+            Log.i(this, "Mods repo error: " + code);
+        }
+    }
+
+    private class AsyncLoadLocalMods extends AsyncTask<Void, Void, Void>
+    {
+        @Override
+        protected void onPreExecute()
+        {
+            mProgress.setVisibility(View.VISIBLE);
+        }
+
+        @Override
+        protected Void doInBackground(final Void... params)
+        {
+            try
+            {
+                loadLocalModData();
+            }
+            catch (IOException e)
+            {
+                Log.e(this, "Loading local mod data failed", e);
+            }
+            catch (JSONException e)
+            {
+                Log.e(this, "Parsing local mod data failed", e);
+            }
+            return null;
+        }
+
+        @Override
+        protected void onPostExecute(final Void aVoid)
+        {
+            if (mModContainer == null || !mModContainer.hasSubmods())
+            {
+                handleNoData();
+            }
+            else
+            {
+                mProgress.setVisibility(View.GONE);
+                mRecycler.setVisibility(View.VISIBLE);
+                mModsAdapter.updateModsList(mModContainer.submods());
+            }
+        }
+    }
+
+    private class OnAdapterItemAction implements ModsAdapter.IOnItemAction
+    {
+        @Override
+        public void onItemPressed(final ModsAdapter.ModItem mod, final RecyclerView.ViewHolder vh)
+        {
+            Log.i(this, "Mod pressed: " + mod);
+            if (mod.mMod.hasSubmods())
+            {
+                if (mod.mExpanded)
+                {
+                    mModsAdapter.detachSubmods(mod, vh);
+                }
+                else
+                {
+                    mModsAdapter.attachSubmods(mod, vh);
+                    mRecycler.scrollToPosition(vh.getAdapterPosition() + 1);
+                }
+                mod.mExpanded = !mod.mExpanded;
+            }
+        }
+
+        @Override
+        public void onDownloadPressed(final ModsAdapter.ModItem mod, final RecyclerView.ViewHolder vh)
+        {
+            Log.i(this, "Mod download pressed: " + mod);
+            mModsAdapter.downloadProgress(mod, "0%");
+            installModAsync(mod);
+        }
+
+        @Override
+        public void onTogglePressed(final ModsAdapter.ModItem item, final ModBaseViewHolder holder)
+        {
+            if(!item.mMod.mSystem && item.mMod.mInstalled)
+            {
+                item.mMod.mActive = !item.mMod.mActive;
+                mModsAdapter.notifyItemChanged(holder.getAdapterPosition());
+                saveModSettingsToFile();
+            }
+        }
+
+        @Override
+        public void onUninstall(ModsAdapter.ModItem item, ModBaseViewHolder holder)
+        {
+            File installationFolder = item.mMod.installationFolder;
+            ActivityMods activity = ActivityMods.this;
+
+            if(installationFolder != null){
+                new AlertDialog.Builder(activity)
+                    .setTitle(activity.getString(R.string.mods_removal_title, item.mMod.mName))
+                    .setMessage(activity.getString(R.string.mods_removal_confirmation, item.mMod.mName))
+                    .setIcon(android.R.drawable.ic_dialog_alert)
+                    .setNegativeButton(android.R.string.no, null)
+                    .setPositiveButton(android.R.string.yes, (dialog, whichButton) ->
+                    {
+                        FileUtil.clearDirectory(installationFolder);
+                        installationFolder.delete();
+
+                        mModsAdapter.modRemoved(item);
+                    })
+                    .show();
+            }
+        }
+    }
+
+    private void installModAsync(ModsAdapter.ModItem mod){
+        File dataDir = Storage.getVcmiDataDir(this);
+        File modFolder = new File(
+                new File(dataDir, "Mods"),
+                mod.mMod.mId.toLowerCase(Locale.US));
+
+        InstallModAsync modInstaller = new InstallModAsync(
+            modFolder,
+            this,
+            new InstallModCallback(mod)
+        );
+
+        modInstaller.execute(mod.mMod.mArchiveUrl);
+    }
+
+    public class InstallModCallback implements InstallModAsync.PostDownload
+    {
+        private ModsAdapter.ModItem mod;
+
+        public InstallModCallback(ModsAdapter.ModItem mod)
+        {
+            this.mod = mod;
+        }
+
+        @Override
+        public void downloadDone(Boolean succeed, File modFolder)
+        {
+            if(succeed){
+                mModsAdapter.modInstalled(mod, modFolder);
+            }
+        }
+
+        @Override
+        public void downloadProgress(String... progress)
+        {
+            if(progress.length > 0)
+            {
+                mModsAdapter.downloadProgress(mod, progress[0]);
+            }
+        }
+    }
+}

+ 53 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ActivityWithToolbar.java

@@ -0,0 +1,53 @@
+package eu.vcmi.vcmi;
+
+import androidx.appcompat.app.ActionBar;
+import androidx.appcompat.widget.Toolbar;
+import android.view.MenuItem;
+import android.view.ViewStub;
+
+/**
+ * @author F
+ */
+public abstract class ActivityWithToolbar extends ActivityBase
+{
+    @Override
+    public void setContentView(final int layoutResId)
+    {
+        super.setContentView(R.layout.activity_toolbar_wrapper);
+        final ViewStub contentStub = (ViewStub) findViewById(R.id.toolbar_wrapper_content_stub);
+        contentStub.setLayoutResource(layoutResId);
+        contentStub.inflate();
+    }
+
+    @Override
+    public boolean onOptionsItemSelected(final MenuItem item)
+    {
+        if (item.getItemId() == android.R.id.home)
+        {
+            finish();
+            return true;
+        }
+        return super.onOptionsItemSelected(item);
+    }
+
+    protected void initToolbar(final int textResId)
+    {
+        initToolbar(textResId, false);
+    }
+
+    protected void initToolbar(final int textResId, final boolean isTopLevelActivity)
+    {
+        final Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
+        setSupportActionBar(toolbar);
+        toolbar.setTitle(textResId);
+
+        if (!isTopLevelActivity)
+        {
+            final ActionBar bar = getSupportActionBar();
+            if (bar != null)
+            {
+                bar.setDisplayHomeAsUpEnabled(true);
+            }
+        }
+    }
+}

+ 233 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/Config.java

@@ -0,0 +1,233 @@
+package eu.vcmi.vcmi;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class Config
+{
+    public static final String DEFAULT_CODEPAGE = "CP1250";
+    public static final int DEFAULT_MUSIC_VALUE = 5;
+    public static final int DEFAULT_SOUND_VALUE = 5;
+    public static final int DEFAULT_SCREEN_RES_W = 800;
+    public static final int DEFAULT_SCREEN_RES_H = 600;
+
+    public String mCodepage;
+    public int mResolutionWidth;
+    public int mResolutionHeight;
+    public boolean mSwipeEnabled;
+    public int mVolumeSound;
+    public int mVolumeMusic;
+    private String adventureAi;
+    private double mPointerSpeedMultiplier;
+    private boolean mUseRelativePointer;
+    private JSONObject mRawObject;
+
+    private boolean mIsModified;
+
+    private static JSONObject accessNode(final JSONObject baseObj, String type)
+    {
+        if (baseObj == null)
+        {
+            return null;
+        }
+
+        return baseObj.optJSONObject(type);
+    }
+
+    private static JSONObject accessScreenResNode(final JSONObject baseObj)
+    {
+        if (baseObj == null)
+        {
+            return null;
+        }
+
+        final JSONObject video = baseObj.optJSONObject("video");
+        if (video != null)
+        {
+            return video.optJSONObject("screenRes");
+        }
+        return null;
+    }
+
+    private static double loadDouble(final JSONObject node, final String key, final double fallback)
+    {
+        if (node == null)
+        {
+            return fallback;
+        }
+
+        return node.optDouble(key, fallback);
+    }
+
+    @SuppressWarnings("unchecked")
+    private static <T> T loadEntry(final JSONObject node, final String key, final T fallback)
+    {
+        if (node == null)
+        {
+            return fallback;
+        }
+        final Object value = node.opt(key);
+        return value == null ? fallback : (T) value;
+    }
+
+    public static Config load(final JSONObject obj)
+    {
+        Log.v("loading config from json: " + obj.toString());
+        final Config config = new Config();
+        final JSONObject general = accessNode(obj, "general");
+        final JSONObject server = accessNode(obj, "server");
+        config.mCodepage = loadEntry(general, "encoding", DEFAULT_CODEPAGE);
+        config.mVolumeSound = loadEntry(general, "sound", DEFAULT_SOUND_VALUE);
+        config.mVolumeMusic = loadEntry(general, "music", DEFAULT_MUSIC_VALUE);
+        config.mSwipeEnabled = loadEntry(general, "swipe", true);
+        config.adventureAi = loadEntry(server, "playerAI", "VCAI");
+        config.mUseRelativePointer = loadEntry(general, "userRelativePointer", false);
+        config.mPointerSpeedMultiplier = loadDouble(general, "relativePointerSpeedMultiplier", 1.0);
+
+        final JSONObject screenRes = accessScreenResNode(obj);
+        config.mResolutionWidth = loadEntry(screenRes, "width", DEFAULT_SCREEN_RES_W);
+        config.mResolutionHeight = loadEntry(screenRes, "height", DEFAULT_SCREEN_RES_H);
+
+        config.mRawObject = obj;
+        return config;
+    }
+
+    public void updateCodepage(final String s)
+    {
+        mCodepage = s;
+        mIsModified = true;
+    }
+
+    public void updateResolution(final int x, final int y)
+    {
+        mResolutionWidth = x;
+        mResolutionHeight = y;
+        mIsModified = true;
+    }
+
+    public void updateSwipe(final boolean b)
+    {
+        mSwipeEnabled = b;
+        mIsModified = true;
+    }
+
+    public void updateSound(final int i)
+    {
+        mVolumeSound = i;
+        mIsModified = true;
+    }
+
+    public void updateMusic(final int i)
+    {
+        mVolumeMusic = i;
+        mIsModified = true;
+    }
+
+    public void setAdventureAi(String ai)
+    {
+        adventureAi = ai;
+        mIsModified = true;
+    }
+
+    public String getAdventureAi()
+    {
+        return this.adventureAi == null ? "VCAI" : this.adventureAi;
+    }
+
+    public void setPointerSpeedMultiplier(float speedMultiplier)
+    {
+        mPointerSpeedMultiplier = speedMultiplier;
+        mIsModified = true;
+    }
+
+    public float getPointerSpeedMultiplier()
+    {
+        return (float)mPointerSpeedMultiplier;
+    }
+
+    public void setPointerMode(boolean isRelative)
+    {
+        mUseRelativePointer = isRelative;
+        mIsModified = true;
+    }
+
+    public boolean getPointerModeIsRelative()
+    {
+        return mUseRelativePointer;
+    }
+
+    public void save(final File location) throws IOException, JSONException
+    {
+        if (!needsSaving(location))
+        {
+            Log.d(this, "Config doesn't need saving");
+            return;
+        }
+        try
+        {
+            final String configString = toJson();
+            FileUtil.write(location, configString);
+            Log.v(this, "Saved config: " + configString);
+        }
+        catch (final Exception e)
+        {
+            Log.e(this, "Could not save config", e);
+            throw e;
+        }
+    }
+
+    private boolean needsSaving(final File location)
+    {
+        return mIsModified || !location.exists();
+    }
+
+    private String toJson() throws JSONException
+    {
+        final JSONObject generalNode = accessNode(mRawObject, "general");
+        final JSONObject serverNode = accessNode(mRawObject, "server");
+        final JSONObject screenResNode = accessScreenResNode(mRawObject);
+
+        final JSONObject root = mRawObject == null ? new JSONObject() : mRawObject;
+        final JSONObject general = generalNode == null ? new JSONObject() : generalNode;
+        final JSONObject video = new JSONObject();
+        final JSONObject screenRes = screenResNode == null ? new JSONObject() : screenResNode;
+        final JSONObject server = serverNode == null ? new JSONObject() : serverNode;
+
+        if (mCodepage != null)
+        {
+            general.put("encoding", mCodepage);
+        }
+
+        general.put("swipe", mSwipeEnabled);
+        general.put("music", mVolumeMusic);
+        general.put("sound", mVolumeSound);
+        general.put("userRelativePointer", mUseRelativePointer);
+        general.put("relativePointerSpeedMultiplier", mPointerSpeedMultiplier);
+        root.put("general", general);
+
+        if(this.adventureAi != null)
+        {
+            server.put("playerAI", this.adventureAi);
+            root.put("server", server);
+        }
+
+        if (mResolutionHeight > 0 && mResolutionWidth > 0)
+        {
+            screenRes.put("width", mResolutionWidth);
+            screenRes.put("height", mResolutionHeight);
+            video.put("screenRes", screenRes);
+            root.put("video", video);
+        }
+
+        return root.toString();
+    }
+}

+ 24 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/Const.java

@@ -0,0 +1,24 @@
+package eu.vcmi.vcmi;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.Environment;
+
+import java.io.BufferedWriter;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStreamWriter;
+
+/**
+ * @author F
+ */
+public class Const
+{
+    public static final String JNI_METHOD_SUPPRESS = "unused"; // jni methods are marked as unused, because IDE doesn't understand jni calls
+    // used to disable lint errors about try-with-resources being unsupported on api <19 (it is supported, because retrolambda backports it)
+    public static final int SUPPRESS_TRY_WITH_RESOURCES_WARNING = Build.VERSION_CODES.KITKAT;
+
+    public static final String VCMI_DATA_ROOT_FOLDER_NAME = "vcmi-data";
+}

+ 163 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/NativeMethods.java

@@ -0,0 +1,163 @@
+package eu.vcmi.vcmi;
+
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Environment;
+import android.os.Looper;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+
+import org.libsdl.app.SDL;
+import org.libsdl.app.SDLActivity;
+
+import java.io.File;
+import java.lang.ref.WeakReference;
+
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class NativeMethods
+{
+    private static WeakReference<Messenger> serverMessengerRef;
+
+    public NativeMethods()
+    {
+    }
+
+    public static native void initClassloader();
+
+    public static native void clientSetupJNI();
+
+    public static native void createServer();
+
+    public static native void notifyServerReady();
+
+    public static native void notifyServerClosed();
+
+    public static native boolean tryToSaveTheGame();
+
+    public static void setupMsg(final Messenger msg)
+    {
+        serverMessengerRef = new WeakReference<>(msg);
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static String dataRoot()
+    {
+        final Context ctx = SDL.getContext();
+        String root = Storage.getVcmiDataDir(ctx).getAbsolutePath();
+
+        Log.i("Accessing data root: " + root);
+        return root;
+    }
+
+    // this path is visible only to this application; we can store base vcmi configs etc. there
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static String internalDataRoot()
+    {
+        final Context ctx = SDL.getContext();
+        String root = new File(ctx.getFilesDir(), Const.VCMI_DATA_ROOT_FOLDER_NAME).getAbsolutePath();
+        Log.i("Accessing internal data root: " + root);
+        return root;
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static String nativePath()
+    {
+        final Context ctx = SDL.getContext();
+        Log.i("Accessing ndk path: " + ctx.getApplicationInfo().nativeLibraryDir);
+        return ctx.getApplicationInfo().nativeLibraryDir;
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static void startServer()
+    {
+        Log.i("Got server create request");
+        final Context ctx = SDL.getContext();
+
+        if (!(ctx instanceof VcmiSDLActivity))
+        {
+            Log.e("Unexpected context... " + ctx);
+            return;
+        }
+
+        Intent intent = new Intent(ctx, SDLActivity.class);
+        intent.setAction(VcmiSDLActivity.NATIVE_ACTION_CREATE_SERVER);
+        // I probably do something incorrectly, but sending new intent to the activity "normally" breaks SDL events handling (probably detaches jnienv?)
+        // so instead let's call onNewIntent directly, as out context SHOULD be SDLActivity anyway
+        ((VcmiSDLActivity) ctx).hackCallNewIntentDirectly(intent);
+//        ctx.startActivity(intent);
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static void killServer()
+    {
+        Log.i("Got server close request");
+
+        final Context ctx = SDL.getContext();
+        ctx.stopService(new Intent(ctx, ServerService.class));
+
+        Messenger messenger = requireServerMessenger();
+        try
+        {
+            // we need to actually inform client about killing the server, beacuse it needs to unbind service connection before server gets destroyed
+            messenger.send(Message.obtain(null, VcmiSDLActivity.SERVER_MESSAGE_SERVER_KILLED));
+        }
+        catch (RemoteException e)
+        {
+            Log.w("Connection with client process broken?");
+        }
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static void onServerReady()
+    {
+        Log.i("Got server ready msg");
+        Messenger messenger = requireServerMessenger();
+
+        try
+        {
+            messenger.send(Message.obtain(null, VcmiSDLActivity.SERVER_MESSAGE_SERVER_READY));
+        }
+        catch (RemoteException e)
+        {
+            Log.w("Connection with client process broken?");
+        }
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static void showProgress()
+    {
+        internalProgressDisplay(true);
+    }
+
+    @SuppressWarnings(Const.JNI_METHOD_SUPPRESS)
+    public static void hideProgress()
+    {
+        internalProgressDisplay(false);
+    }
+
+    private static void internalProgressDisplay(final boolean show)
+    {
+        final Context ctx = SDL.getContext();
+        if (!(ctx instanceof VcmiSDLActivity))
+        {
+            return;
+        }
+        ((SDLActivity) ctx).runOnUiThread(() -> ((VcmiSDLActivity) ctx).displayProgress(show));
+    }
+
+    private static Messenger requireServerMessenger()
+    {
+        Messenger msg = serverMessengerRef.get();
+        if (msg == null)
+        {
+            throw new RuntimeException("Broken server messenger");
+        }
+        return msg;
+    }
+}

+ 107 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/ServerService.java

@@ -0,0 +1,107 @@
+package eu.vcmi.vcmi;
+
+import android.app.Service;
+import android.content.Intent;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Message;
+import android.os.Messenger;
+
+import org.libsdl.app.SDL;
+
+import java.lang.ref.WeakReference;
+
+import eu.vcmi.vcmi.util.LibsLoader;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class ServerService extends Service
+{
+    public static final int CLIENT_MESSAGE_CLIENT_REGISTERED = 1;
+    public static final String INTENT_ACTION_KILL_SERVER = "ServerService.Action.Kill";
+    final Messenger mMessenger = new Messenger(new IncomingClientMessageHandler(new OnClientRegisteredCallback()));
+    private Messenger mClient;
+
+    @Override
+    public IBinder onBind(Intent intent)
+    {
+        return mMessenger.getBinder();
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId)
+    {
+        SDL.setContext(ServerService.this);
+        LibsLoader.loadServerLibs();
+        if (INTENT_ACTION_KILL_SERVER.equals(intent.getAction()))
+        {
+            stopSelf();
+        }
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public void onDestroy()
+    {
+        super.onDestroy();
+        Log.i(this, "destroyed");
+        // we need to kill the process to ensure all server data is cleaned up; this isn't a good solution (as we mess with system's
+        // memory management stuff), but clearing all native data manually would be a pain and we can't force close the server "gracefully", because
+        // even after onDestroy call, the system can postpone actually finishing the process -- this would break CVCMIServer initialization
+        System.exit(0);
+    }
+
+    private interface IncomingClientMessageHandlerCallback
+    {
+        void onClientRegistered(Messenger client);
+    }
+
+    private static class ServerStartThread extends Thread
+    {
+        @Override
+        public void run()
+        {
+            NativeMethods.createServer();
+        }
+    }
+
+    private static class IncomingClientMessageHandler extends Handler
+    {
+        private WeakReference<IncomingClientMessageHandlerCallback> mCallbackRef;
+
+        IncomingClientMessageHandler(final IncomingClientMessageHandlerCallback callback)
+        {
+            mCallbackRef = new WeakReference<>(callback);
+        }
+
+        @Override
+        public void handleMessage(Message msg)
+        {
+            switch (msg.what)
+            {
+                case CLIENT_MESSAGE_CLIENT_REGISTERED:
+                    final IncomingClientMessageHandlerCallback callback = mCallbackRef.get();
+                    if (callback != null)
+                    {
+                        callback.onClientRegistered(msg.replyTo);
+                    }
+                    NativeMethods.setupMsg(msg.replyTo);
+                    new ServerStartThread().start();
+                    break;
+                default:
+                    super.handleMessage(msg);
+            }
+        }
+    }
+
+    private class OnClientRegisteredCallback implements IncomingClientMessageHandlerCallback
+    {
+        @Override
+        public void onClientRegistered(final Messenger client)
+        {
+            mClient = client;
+        }
+    }
+}

+ 31 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/Storage.java

@@ -0,0 +1,31 @@
+package eu.vcmi.vcmi;
+
+import android.content.Context;
+import java.io.File;
+import java.io.IOException;
+import eu.vcmi.vcmi.util.FileUtil;
+
+public class Storage
+{
+    public static File getVcmiDataDir(Context context)
+    {
+        File root = context.getExternalFilesDir(null);
+
+        return new File(root, Const.VCMI_DATA_ROOT_FOLDER_NAME);
+    }
+
+    public static boolean testH3DataFolder(Context context)
+    {
+        return testH3DataFolder(getVcmiDataDir(context));
+    }
+
+    public static boolean testH3DataFolder(final File baseDir)
+    {
+        final File testH3Data = new File(baseDir, "Data");
+        return testH3Data.exists();
+    }
+
+    public static String getH3DataFolder(Context context){
+        return getVcmiDataDir(context).getAbsolutePath();
+    }
+}

+ 206 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/VcmiSDLActivity.java

@@ -0,0 +1,206 @@
+package eu.vcmi.vcmi;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.Intent;
+import android.content.ServiceConnection;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Message;
+import android.os.Messenger;
+import android.os.RemoteException;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.libsdl.app.SDLActivity;
+
+import eu.vcmi.vcmi.util.LibsLoader;
+import eu.vcmi.vcmi.util.Log;
+
+public class VcmiSDLActivity extends SDLActivity
+{
+    public static final int SERVER_MESSAGE_SERVER_READY = 1000;
+    public static final int SERVER_MESSAGE_SERVER_KILLED = 1001;
+    public static final String NATIVE_ACTION_CREATE_SERVER = "SDLActivity.Action.CreateServer";
+    protected static final int COMMAND_USER = 0x8000;
+
+    final Messenger mClientMessenger = new Messenger(
+            new IncomingServerMessageHandler(
+                    new OnServerRegisteredCallback()));
+    Messenger mServiceMessenger = null;
+    boolean mIsServerServiceBound;
+    private View mProgressBar;
+
+    private ServiceConnection mServerServiceConnection = new ServiceConnection()
+    {
+        public void onServiceConnected(ComponentName className,
+                                       IBinder service)
+        {
+            Log.i(this, "Service connection");
+            mServiceMessenger = new Messenger(service);
+            mIsServerServiceBound = true;
+
+            try
+            {
+                Message msg = Message.obtain(null, ServerService.CLIENT_MESSAGE_CLIENT_REGISTERED);
+                msg.replyTo = mClientMessenger;
+                mServiceMessenger.send(msg);
+            }
+            catch (RemoteException ignored)
+            {
+            }
+        }
+
+        public void onServiceDisconnected(ComponentName className)
+        {
+            Log.i(this, "Service disconnection");
+            mServiceMessenger = null;
+        }
+    };
+
+    public void hackCallNewIntentDirectly(final Intent intent)
+    {
+        onNewIntent(intent);
+    }
+
+    public void displayProgress(final boolean show)
+    {
+        if (mProgressBar != null)
+        {
+            mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
+        }
+    }
+
+    @Override
+    public void loadLibraries()
+    {
+        LibsLoader.loadClientLibs(this);
+    }
+
+    @Override
+    protected String getMainSharedObject() {
+        String library = "libvcmi-client.so";
+
+        return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
+    }
+
+    @Override
+    protected void onNewIntent(final Intent intent)
+    {
+        Log.i(this, "Got new intent with action " + intent.getAction());
+        if (NATIVE_ACTION_CREATE_SERVER.equals(intent.getAction()))
+        {
+            initService();
+        }
+    }
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState)
+    {
+        super.onCreate(savedInstanceState);
+
+        if(mBrokenLibraries)
+            return;
+
+        final View outerLayout = getLayoutInflater().inflate(R.layout.activity_game, null, false);
+        final ViewGroup layout = (ViewGroup) outerLayout.findViewById(R.id.game_outer_frame);
+        mProgressBar = outerLayout.findViewById(R.id.game_progress);
+
+        mLayout.removeView(mSurface);
+        layout.addView(mSurface);
+        mLayout = layout;
+
+        setContentView(outerLayout);
+    }
+
+    @Override
+    protected void onDestroy()
+    {
+        try
+        {
+            // since android can kill the activity unexpectedly (e.g. memory is low or device is inactive for some time), let's try creating
+            // an autosave so user might be able to resume the game; this isn't a very good impl (we shouldn't really sleep here and hope that the
+            // save is created, but for now it might suffice
+            // (better solution: listen for game's confirmation that the save has been created -- this would allow us to inform the users
+            // on the next app launch that there is an automatic save that they can use)
+            if (NativeMethods.tryToSaveTheGame())
+            {
+                Thread.sleep(1000L);
+            }
+        }
+        catch (final InterruptedException ignored)
+        {
+        }
+
+        unbindServer();
+
+        super.onDestroy();
+    }
+
+    private void initService()
+    {
+        unbindServer();
+        startService(new Intent(this, ServerService.class));
+        bindService(
+            new Intent(VcmiSDLActivity.this, ServerService.class),
+            mServerServiceConnection,
+            Context.BIND_AUTO_CREATE);
+    }
+
+    private void unbindServer()
+    {
+        Log.d(this, "Unbinding server " + mIsServerServiceBound);
+        if (mIsServerServiceBound)
+        {
+            unbindService(mServerServiceConnection);
+            mIsServerServiceBound = false;
+        }
+    }
+
+    private interface IncomingServerMessageHandlerCallback
+    {
+        void unbindServer();
+    }
+
+    private class OnServerRegisteredCallback implements IncomingServerMessageHandlerCallback
+    {
+        @Override
+        public void unbindServer()
+        {
+            VcmiSDLActivity.this.unbindServer();
+        }
+    }
+
+    private static class IncomingServerMessageHandler extends Handler
+    {
+        private VcmiSDLActivity.IncomingServerMessageHandlerCallback mCallback;
+
+        IncomingServerMessageHandler(
+                final VcmiSDLActivity.IncomingServerMessageHandlerCallback callback)
+        {
+            mCallback = callback;
+        }
+
+        @Override
+        public void handleMessage(Message msg)
+        {
+            Log.i(this, "Got server msg " + msg);
+            switch (msg.what)
+            {
+                case SERVER_MESSAGE_SERVER_READY:
+                    NativeMethods.notifyServerReady();
+                    break;
+                case SERVER_MESSAGE_SERVER_KILLED:
+                    if (mCallback != null)
+                    {
+                        mCallback.unbindServer();
+                    }
+                    NativeMethods.notifyServerClosed();
+                    break;
+                default:
+                    super.handleMessage(msg);
+            }
+        }
+    }
+}

+ 172 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/AsyncLauncherInitialization.java

@@ -0,0 +1,172 @@
+package eu.vcmi.vcmi.content;
+
+import android.Manifest;
+import android.app.Activity;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.os.Environment;
+import androidx.core.app.ActivityCompat;
+
+import java.io.File;
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.Const;
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.Storage;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.LegacyConfigReader;
+import eu.vcmi.vcmi.util.Log;
+import eu.vcmi.vcmi.util.SharedPrefs;
+
+/**
+ * @author F
+ */
+public class AsyncLauncherInitialization extends AsyncTask<Void, Void, AsyncLauncherInitialization.InitResult>
+{
+    private final WeakReference<ILauncherCallbacks> mCallbackRef;
+
+    public AsyncLauncherInitialization(final ILauncherCallbacks callback)
+    {
+        mCallbackRef = new WeakReference<>(callback);
+    }
+
+    private InitResult init()
+    {
+        InitResult initResult = handleDataFoldersInitialization();
+
+        if (!initResult.mSuccess)
+        {
+            return initResult;
+        }
+        Log.d(this, "Folders check passed");
+
+        return initResult;
+    }
+
+    private InitResult handleDataFoldersInitialization()
+    {
+        final ILauncherCallbacks callbacks = mCallbackRef.get();
+
+        if (callbacks == null)
+        {
+            return InitResult.failure("Internal error");
+        }
+
+        final Context ctx = callbacks.ctx();
+        final File vcmiDir = Storage.getVcmiDataDir(ctx);
+
+        final File internalDir = ctx.getFilesDir();
+        final File vcmiInternalDir = new File(internalDir, Const.VCMI_DATA_ROOT_FOLDER_NAME);
+        Log.i(this, "Using " + vcmiDir.getAbsolutePath() + " as root vcmi dir");
+
+        if(!vcmiInternalDir.exists()) vcmiInternalDir.mkdir();
+        if(!vcmiDir.exists()) vcmiDir.mkdir();
+
+        if (!Storage.testH3DataFolder(ctx))
+        {
+            // no h3 data present -> instruct user where to put it
+            return InitResult.failure(
+                ctx.getString(
+                    R.string.launcher_error_h3_data_missing,
+                        Storage.getVcmiDataDir(ctx)));
+        }
+
+        final File testVcmiData = new File(vcmiInternalDir, "Mods/vcmi/mod.json");
+        final boolean internalVcmiDataExisted = testVcmiData.exists();
+        if (!internalVcmiDataExisted && !FileUtil.unpackVcmiDataToInternalDir(vcmiInternalDir, ctx.getAssets()))
+        {
+            // no h3 data present -> instruct user where to put it
+            return InitResult.failure(ctx.getString(R.string.launcher_error_vcmi_data_internal_missing));
+        }
+
+        final String previousInternalDataHash = callbacks.prefs().load(SharedPrefs.KEY_CURRENT_INTERNAL_ASSET_HASH, null);
+        final String currentInternalDataHash = FileUtil.readAssetsStream(ctx.getAssets(), "internalDataHash.txt");
+        if (currentInternalDataHash == null || previousInternalDataHash == null || !currentInternalDataHash.equals(previousInternalDataHash))
+        {
+            // we should update the data only if it existed previously (hash is bound to be empty if we have just created the data)
+            if (internalVcmiDataExisted)
+            {
+                Log.i(this, "Internal data needs to be created/updated; old hash=" + previousInternalDataHash
+                            + ", new hash=" + currentInternalDataHash);
+                if (!FileUtil.reloadVcmiDataToInternalDir(vcmiInternalDir, ctx.getAssets()))
+                {
+                    return InitResult.failure(ctx.getString(R.string.launcher_error_vcmi_data_internal_update));
+                }
+            }
+            callbacks.prefs().save(SharedPrefs.KEY_CURRENT_INTERNAL_ASSET_HASH, currentInternalDataHash);
+        }
+
+        return InitResult.success();
+    }
+
+    @Override
+    protected InitResult doInBackground(final Void... params)
+    {
+        return init();
+    }
+
+    @Override
+    protected void onPostExecute(final InitResult initResult)
+    {
+        final ILauncherCallbacks callbacks = mCallbackRef.get();
+        if (callbacks == null)
+        {
+            return;
+        }
+
+        if (initResult.mSuccess)
+        {
+            callbacks.onInitSuccess();
+        }
+        else
+        {
+            callbacks.onInitFailure(initResult);
+        }
+    }
+
+    public interface ILauncherCallbacks
+    {
+        Activity ctx();
+
+        SharedPrefs prefs();
+
+        void onInitSuccess();
+
+        void onInitFailure(InitResult result);
+    }
+
+    public static final class InitResult
+    {
+        public final boolean mSuccess;
+        public final String mMessage;
+        public boolean mFailSilently;
+
+        public InitResult(final boolean success, final String message)
+        {
+            mSuccess = success;
+            mMessage = message;
+        }
+
+        @Override
+        public String toString()
+        {
+            return String.format("success: %s (%s)", mSuccess, mMessage);
+        }
+
+        public static InitResult failure(String message)
+        {
+            return new InitResult(false, message);
+        }
+
+        public static InitResult success()
+        {
+            return new InitResult(true, "");
+        }
+    }
+}

+ 52 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/DialogAuthors.java

@@ -0,0 +1,52 @@
+package eu.vcmi.vcmi.content;
+
+import android.annotation.SuppressLint;
+import android.app.Dialog;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import androidx.appcompat.app.AlertDialog;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.widget.TextView;
+
+import java.io.IOException;
+
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class DialogAuthors extends DialogFragment
+{
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(final Bundle savedInstanceState)
+    {
+        final LayoutInflater inflater = LayoutInflater.from(getActivity());
+        @SuppressLint("InflateParams") final View inflated = inflater.inflate(R.layout.dialog_authors, null, false);
+        final TextView vcmiAuthorsView = (TextView) inflated.findViewById(R.id.dialog_authors_vcmi);
+        final TextView launcherAuthorsView = (TextView) inflated.findViewById(R.id.dialog_authors_launcher);
+        loadAuthorsContent(vcmiAuthorsView, launcherAuthorsView);
+        return new AlertDialog.Builder(getActivity())
+            .setView(inflated)
+            .create();
+    }
+
+    private void loadAuthorsContent(final TextView vcmiAuthorsView, final TextView launcherAuthorsView)
+    {
+        try
+        {
+            // to be checked if this should be converted to async load (not really a file operation so it should be okay)
+            final String authorsContent = FileUtil.read(getResources().openRawResource(R.raw.authors));
+            vcmiAuthorsView.setText(authorsContent);
+            launcherAuthorsView.setText("Fay"); // TODO hardcoded for now
+        }
+        catch (final IOException e)
+        {
+            Log.e(this, "Could not load authors content", e);
+        }
+    }
+}

+ 36 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModBaseViewHolder.java

@@ -0,0 +1,36 @@
+package eu.vcmi.vcmi.content;
+
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class ModBaseViewHolder extends RecyclerView.ViewHolder
+{
+    final View mModNesting;
+    final TextView mModName;
+
+    ModBaseViewHolder(final View parentView)
+    {
+        this(
+            LayoutInflater.from(parentView.getContext()).inflate(
+                R.layout.mod_base_adapter_item,
+                (ViewGroup) parentView,
+                false),
+            true);
+    }
+
+    protected ModBaseViewHolder(final View v, final boolean internal)
+    {
+        super(v);
+        mModNesting = itemView.findViewById(R.id.mods_adapter_item_nesting);
+        mModName = (TextView) itemView.findViewById(R.id.mods_adapter_item_name);
+    }
+}

+ 254 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsAdapter.java

@@ -0,0 +1,254 @@
+package eu.vcmi.vcmi.content;
+
+import android.content.Context;
+import androidx.recyclerview.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.mods.VCMIMod;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class ModsAdapter extends RecyclerView.Adapter<ModBaseViewHolder>
+{
+    private static final int NESTING_WIDTH_PER_LEVEL = 16;
+    private static final int VIEWTYPE_MOD = 0;
+    private static final int VIEWTYPE_FAILED_MOD = 1;
+    private final List<ModItem> mDataset = new ArrayList<>();
+    private final IOnItemAction mItemListener;
+
+    public ModsAdapter(final IOnItemAction itemListener)
+    {
+        mItemListener = itemListener;
+    }
+
+    @Override
+    public ModBaseViewHolder onCreateViewHolder(final ViewGroup parent, final int viewType)
+    {
+        switch (viewType)
+        {
+            case VIEWTYPE_MOD:
+                return new ModsViewHolder(parent);
+            case VIEWTYPE_FAILED_MOD:
+                return new ModBaseViewHolder(parent);
+            default:
+                Log.e(this, "Unhandled view type: " + viewType);
+                return null;
+        }
+    }
+
+    @Override
+    public void onBindViewHolder(final ModBaseViewHolder holder, final int position)
+    {
+        final ModItem item = mDataset.get(position);
+        final int viewType = getItemViewType(position);
+
+        final Context ctx = holder.itemView.getContext();
+        holder.mModNesting.getLayoutParams().width = item.mNestingLevel * NESTING_WIDTH_PER_LEVEL;
+        switch (viewType)
+        {
+            case VIEWTYPE_MOD:
+                final ModsViewHolder modHolder = (ModsViewHolder) holder;
+                modHolder.mModName.setText(item.mMod.mName + ", " + item.mMod.mVersion);
+                modHolder.mModType.setText(item.mMod.mModType);
+                if (item.mMod.mSize > 0)
+                {
+                    modHolder.mModSize.setVisibility(View.VISIBLE);
+                    // TODO unit conversion
+                    modHolder.mModSize.setText(String.format(Locale.getDefault(), "%.1f kB", item.mMod.mSize / 1024.0f));
+                }
+                else
+                {
+                    modHolder.mModSize.setVisibility(View.GONE);
+                }
+                modHolder.mModAuthor.setText(ctx.getString(R.string.mods_item_author_template, item.mMod.mAuthor));
+                modHolder.mStatusIcon.setImageResource(selectModStatusIcon(item.mMod.mActive));
+
+                modHolder.mDownloadBtn.setVisibility(View.GONE);
+                modHolder.mDownloadProgress.setVisibility(View.GONE);
+                modHolder.mUninstall.setVisibility(View.GONE);
+
+                if(!item.mMod.mSystem)
+                {
+                    if (item.mDownloadProgress != null)
+                    {
+                        modHolder.mDownloadProgress.setText(item.mDownloadProgress);
+                        modHolder.mDownloadProgress.setVisibility(View.VISIBLE);
+                    }
+                    else if (!item.mMod.mInstalled)
+                    {
+                        modHolder.mDownloadBtn.setVisibility(View.VISIBLE);
+                    }
+                    else if (item.mMod.installationFolder != null)
+                    {
+                        modHolder.mUninstall.setVisibility(View.VISIBLE);
+                    }
+
+                    modHolder.itemView.setOnClickListener(v -> mItemListener.onItemPressed(item, holder));
+                    modHolder.mStatusIcon.setOnClickListener(v -> mItemListener.onTogglePressed(item, holder));
+                    modHolder.mDownloadBtn.setOnClickListener(v -> mItemListener.onDownloadPressed(item, holder));
+                    modHolder.mUninstall.setOnClickListener(v -> mItemListener.onUninstall(item, holder));
+                }
+
+                break;
+            case VIEWTYPE_FAILED_MOD:
+                holder.mModName.setText(ctx.getString(R.string.mods_failed_mod_loading, item.mMod.mName));
+                break;
+            default:
+                Log.e(this, "Unhandled view type: " + viewType);
+                break;
+        }
+    }
+
+    private int selectModStatusIcon(final boolean active)
+    {
+        // TODO distinguishing mods that aren't downloaded or have an update available
+        if (active)
+        {
+            return R.drawable.ic_star_full;
+        }
+        return R.drawable.ic_star_empty;
+    }
+
+    @Override
+    public int getItemViewType(final int position)
+    {
+        return mDataset.get(position).mMod.mLoadedCorrectly ? VIEWTYPE_MOD : VIEWTYPE_FAILED_MOD;
+    }
+
+    @Override
+    public int getItemCount()
+    {
+        return mDataset.size();
+    }
+
+    public void attachSubmods(final ModItem mod, final RecyclerView.ViewHolder vh)
+    {
+        int adapterPosition = vh.getAdapterPosition();
+        final List<ModItem> submods = new ArrayList<>();
+
+        for (VCMIMod v : mod.mMod.submods())
+        {
+            ModItem modItem = new ModItem(v, mod.mNestingLevel + 1);
+            submods.add(modItem);
+        }
+
+        mDataset.addAll(adapterPosition + 1, submods);
+        notifyItemRangeInserted(adapterPosition + 1, submods.size());
+    }
+
+    public void detachSubmods(final ModItem mod, final RecyclerView.ViewHolder vh)
+    {
+        final int adapterPosition = vh.getAdapterPosition();
+        final int checkedPosition = adapterPosition + 1;
+        int detachedElements = 0;
+        while (checkedPosition < mDataset.size() && mDataset.get(checkedPosition).mNestingLevel > mod.mNestingLevel)
+        {
+            ++detachedElements;
+            mDataset.remove(checkedPosition);
+        }
+        notifyItemRangeRemoved(checkedPosition, detachedElements);
+    }
+
+    public void updateModsList(List<VCMIMod> mods)
+    {
+        mDataset.clear();
+
+        List<ModItem> list = new ArrayList<>();
+
+        for (VCMIMod mod : mods)
+        {
+            ModItem modItem = new ModItem(mod);
+            list.add(modItem);
+        }
+
+        mDataset.addAll(list);
+
+        notifyDataSetChanged();
+    }
+
+    public void modInstalled(ModItem mod, File modFolder)
+    {
+        try
+        {
+            mod.mMod.updateFromModInfo(modFolder);
+            mod.mMod.mLoadedCorrectly = true;
+            mod.mMod.mActive = true; // active by default
+            mod.mMod.mInstalled = true;
+            mod.mMod.installationFolder = modFolder;
+            mod.mDownloadProgress = null;
+            notifyItemChanged(mDataset.indexOf(mod));
+        }
+        catch (Exception ex)
+        {
+            Log.e("Failed to install mod", ex);
+        }
+    }
+
+    public void downloadProgress(ModItem mod, String progress)
+    {
+        mod.mDownloadProgress = progress;
+        notifyItemChanged(mDataset.indexOf(mod));
+    }
+
+    public void modRemoved(ModItem item)
+    {
+        int itemIndex = mDataset.indexOf(item);
+
+        if(item.mMod.mArchiveUrl != null && item.mMod.mArchiveUrl != "")
+        {
+            item.mMod.mInstalled = false;
+            item.mMod.installationFolder = null;
+
+            notifyItemChanged(itemIndex);
+        }
+        else
+        {
+            mDataset.remove(item);
+            notifyItemRemoved(itemIndex);
+        }
+    }
+
+    public interface IOnItemAction
+    {
+        void onItemPressed(final ModItem mod, final RecyclerView.ViewHolder vh);
+
+        void onDownloadPressed(final ModItem mod, final RecyclerView.ViewHolder vh);
+
+        void onTogglePressed(ModItem item, ModBaseViewHolder holder);
+
+        void onUninstall(ModItem item, ModBaseViewHolder holder);
+    }
+
+    public static class ModItem
+    {
+        public final VCMIMod mMod;
+        public int mNestingLevel;
+        public boolean mExpanded;
+        public String mDownloadProgress;
+
+        public ModItem(final VCMIMod mod)
+        {
+            this(mod, 0);
+        }
+
+        public ModItem(final VCMIMod mod, final int nestingLevel)
+        {
+            mMod = mod;
+            mNestingLevel = nestingLevel;
+            mExpanded = false;
+        }
+
+        @Override
+        public String toString()
+        {
+            return mMod.toString();
+        }
+    }
+}

+ 35 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/content/ModsViewHolder.java

@@ -0,0 +1,35 @@
+package eu.vcmi.vcmi.content;
+
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class ModsViewHolder extends ModBaseViewHolder
+{
+    final TextView mModAuthor;
+    final TextView mModType;
+    final TextView mModSize;
+    final ImageView mStatusIcon;
+    final View mDownloadBtn;
+    final TextView mDownloadProgress;
+    final View mUninstall;
+
+    ModsViewHolder(final View parentView)
+    {
+        super(LayoutInflater.from(parentView.getContext()).inflate(R.layout.mods_adapter_item, (ViewGroup) parentView, false), true);
+        mModAuthor = (TextView) itemView.findViewById(R.id.mods_adapter_item_author);
+        mModType = (TextView) itemView.findViewById(R.id.mods_adapter_item_modtype);
+        mModSize = (TextView) itemView.findViewById(R.id.mods_adapter_item_size);
+        mDownloadBtn = itemView.findViewById(R.id.mods_adapter_item_btn_download);
+        mStatusIcon = (ImageView) itemView.findViewById(R.id.mods_adapter_item_status);
+        mDownloadProgress = (TextView) itemView.findViewById(R.id.mods_adapter_item_install_progress);
+        mUninstall = itemView.findViewById(R.id.mods_adapter_item_btn_uninstall);
+    }
+}

+ 258 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIMod.java

@@ -0,0 +1,258 @@
+package eu.vcmi.vcmi.mods;
+
+import android.text.TextUtils;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+import eu.vcmi.vcmi.BuildConfig;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class VCMIMod
+{
+    protected final Map<String, VCMIMod> mSubmods;
+    public String mId;
+    public String mName;
+    public String mDesc;
+    public String mVersion;
+    public String mAuthor;
+    public String mContact;
+    public String mModType;
+    public String mArchiveUrl;
+    public long mSize;
+    public File installationFolder;
+
+    // config values
+    public boolean mActive;
+    public boolean mInstalled;
+    public boolean mValidated;
+    public String mChecksum;
+
+    // internal
+    public boolean mLoadedCorrectly;
+    public boolean mSystem;
+
+    protected VCMIMod()
+    {
+        mSubmods = new HashMap<>();
+    }
+
+    public static VCMIMod buildFromRepoJson(final String id,
+                                            final JSONObject obj,
+                                            JSONObject modDownloadData)
+    {
+        final VCMIMod mod = new VCMIMod();
+        mod.mId = id.toLowerCase(Locale.US);
+        mod.mName = obj.optString("name");
+        mod.mDesc = obj.optString("description");
+        mod.mVersion = obj.optString("version");
+        mod.mAuthor = obj.optString("author");
+        mod.mContact = obj.optString("contact");
+        mod.mModType = obj.optString("modType");
+        mod.mArchiveUrl = modDownloadData.optString("download");
+        mod.mSize = obj.optLong("size");
+        mod.mLoadedCorrectly = true;
+        return mod;
+    }
+
+    public static VCMIMod buildFromConfigJson(final String id, final JSONObject obj) throws JSONException
+    {
+        final VCMIMod mod = new VCMIMod();
+        mod.updateFromConfigJson(id, obj);
+        mod.mInstalled = true;
+        return mod;
+    }
+
+    public static VCMIMod buildFromModInfo(final File modPath) throws IOException, JSONException
+    {
+        final VCMIMod mod = new VCMIMod();
+        if (!mod.updateFromModInfo(modPath))
+        {
+            return mod;
+        }
+        mod.mLoadedCorrectly = true;
+        mod.mActive = true; // active by default
+        mod.mInstalled = true;
+        mod.installationFolder = modPath;
+
+        return mod;
+    }
+
+    protected static Map<String, VCMIMod> loadSubmods(final List<File> modsList) throws IOException, JSONException
+    {
+        final Map<String, VCMIMod> submods = new HashMap<>();
+        for (final File f : modsList)
+        {
+            if (!f.isDirectory())
+            {
+                Log.w("VCMI", "Non-directory encountered in mods dir: " + f.getName());
+                continue;
+            }
+
+            final VCMIMod submod = buildFromModInfo(f);
+            if (submod == null)
+            {
+                Log.w(null, "Could not build mod in folder " + f + "; ignoring");
+                continue;
+            }
+
+            submods.put(submod.mId, submod);
+        }
+        return submods;
+    }
+
+    public void updateFromConfigJson(final String id, final JSONObject obj) throws JSONException
+    {
+        if(mSystem)
+        {
+            return;
+        }
+
+        mId = id.toLowerCase(Locale.US);
+        mActive = obj.optBoolean("active");
+        mValidated = obj.optBoolean("validated");
+        mChecksum = obj.optString("checksum");
+
+        final JSONObject submods = obj.optJSONObject("mods");
+        if (submods != null)
+        {
+            updateChildrenFromConfigJson(submods);
+        }
+    }
+
+    protected void updateChildrenFromConfigJson(final JSONObject submods) throws JSONException
+    {
+        final JSONArray names = submods.names();
+        for (int i = 0; i < names.length(); ++i)
+        {
+            final String modId = names.getString(i);
+            final String normalizedModId = modId.toLowerCase(Locale.US);
+            if (!mSubmods.containsKey(normalizedModId))
+            {
+                Log.w(this, "Mod present in config but not found in /Mods; ignoring: " + modId);
+                continue;
+            }
+
+            mSubmods.get(normalizedModId).updateFromConfigJson(modId, submods.getJSONObject(modId));
+        }
+    }
+
+    public boolean updateFromModInfo(final File modPath) throws IOException, JSONException
+    {
+        final File modInfoFile = new File(modPath, "mod.json");
+        if (!modInfoFile.exists())
+        {
+            Log.w(this, "Mod info doesn't exist");
+            mName = modPath.getAbsolutePath();
+            return false;
+        }
+        try
+        {
+            final JSONObject modInfoContent = new JSONObject(FileUtil.read(modInfoFile));
+            mId = modPath.getName().toLowerCase(Locale.US);
+            mName = modInfoContent.optString("name");
+            mDesc = modInfoContent.optString("description");
+            mVersion = modInfoContent.optString("version");
+            mAuthor = modInfoContent.optString("author");
+            mContact = modInfoContent.optString("contact");
+            mModType = modInfoContent.optString("modType");
+            mSystem = mId.equals("vcmi");
+
+            final File submodsDir = new File(modPath, "Mods");
+            if (submodsDir.exists())
+            {
+                final List<File> submodsFiles = new ArrayList<>();
+                Collections.addAll(submodsFiles, submodsDir.listFiles());
+                mSubmods.putAll(loadSubmods(submodsFiles));
+            }
+            return true;
+        }
+        catch (final JSONException ex)
+        {
+            mName = modPath.getAbsolutePath();
+            return false;
+        }
+    }
+
+    @Override
+    public String toString()
+    {
+        if (!BuildConfig.DEBUG)
+        {
+            return "";
+        }
+        return String.format("mod:[id:%s,active:%s,submods:[%s]]", mId, mActive, TextUtils.join(",", mSubmods.values()));
+    }
+
+    protected void submodsToJson(final JSONObject modsRoot) throws JSONException
+    {
+        for (final VCMIMod submod : mSubmods.values())
+        {
+            final JSONObject submodEntry = new JSONObject();
+            submod.toJsonInternal(submodEntry);
+            modsRoot.put(submod.mId, submodEntry);
+        }
+    }
+
+    protected void toJsonInternal(final JSONObject root) throws JSONException
+    {
+        root.put("active", mActive);
+        root.put("validated", mValidated);
+        if (!TextUtils.isEmpty(mChecksum))
+        {
+            root.put("checksum", mChecksum);
+        }
+        if (!mSubmods.isEmpty())
+        {
+            JSONObject submods = new JSONObject();
+            submodsToJson(submods);
+            root.put("mods", submods);
+        }
+    }
+
+    public boolean hasSubmods()
+    {
+        return !mSubmods.isEmpty();
+    }
+
+    public List<VCMIMod> submods()
+    {
+        final ArrayList<VCMIMod> ret = new ArrayList<>();
+
+        ret.addAll(mSubmods.values());
+
+        Collections.sort(ret, new Comparator<VCMIMod>()
+        {
+            @Override
+            public int compare(VCMIMod left, VCMIMod right)
+            {
+                return left.mName.compareTo(right.mName);
+            }
+        });
+
+        return ret;
+    }
+
+    protected void updateFrom(VCMIMod other)
+    {
+        this.mModType = other.mModType;
+        this.mAuthor = other.mAuthor;
+        this.mDesc = other.mDesc;
+        this.mArchiveUrl = other.mArchiveUrl;
+    }
+}

+ 106 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModContainer.java

@@ -0,0 +1,106 @@
+package eu.vcmi.vcmi.mods;
+
+import android.text.TextUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+
+import eu.vcmi.vcmi.BuildConfig;
+import eu.vcmi.vcmi.util.FileUtil;
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public class VCMIModContainer extends VCMIMod
+{
+    private VCMIMod mCoreStatus; // kept here to correctly save core object to modSettings
+
+    private VCMIModContainer()
+    {
+    }
+
+    public static VCMIModContainer createContainer(final List<File> modsList) throws IOException, JSONException
+    {
+        final VCMIModContainer mod = new VCMIModContainer();
+        mod.mSubmods.putAll(loadSubmods(modsList));
+        return mod;
+    }
+
+    public void updateContainerFromConfigJson(final JSONObject modsList, final JSONObject coreStatus) throws JSONException
+    {
+        updateChildrenFromConfigJson(modsList);
+        if (coreStatus != null)
+        {
+            mCoreStatus = VCMIMod.buildFromConfigJson("core", coreStatus);
+        }
+    }
+
+    public void updateFromRepo(List<VCMIMod> repoMods){
+        for (VCMIMod mod : repoMods)
+        {
+            final String normalizedModId = mod.mId.toLowerCase(Locale.US);
+
+            if(mSubmods.containsKey(normalizedModId)){
+                VCMIMod existing = mSubmods.get(normalizedModId);
+
+                existing.updateFrom(mod);
+            }
+            else{
+                mSubmods.put(normalizedModId, mod);
+            }
+        }
+    }
+
+    @Override
+    public String toString()
+    {
+        if (!BuildConfig.DEBUG)
+        {
+            return "";
+        }
+        return String.format("mods:[%s]", TextUtils.join(",", mSubmods.values()));
+    }
+
+    public void saveToFile(final File location)
+    {
+        try
+        {
+            FileUtil.write(location, toJson());
+        }
+        catch (Exception e)
+        {
+            Log.e(this, "Could not save mod settings", e);
+        }
+    }
+
+    protected String toJson() throws JSONException
+    {
+        final JSONObject root = new JSONObject();
+        final JSONObject activeMods = new JSONObject();
+        final JSONObject coreStatus = new JSONObject();
+        root.put("activeMods", activeMods);
+        submodsToJson(activeMods);
+
+        coreStatusToJson(coreStatus);
+        root.put("core", coreStatus);
+
+        return root.toString();
+    }
+
+    private void coreStatusToJson(final JSONObject coreStatus) throws JSONException
+    {
+        if (mCoreStatus == null)
+        {
+            mCoreStatus = new VCMIMod();
+            mCoreStatus.mId = "core";
+            mCoreStatus.mActive = true;
+        }
+        mCoreStatus.toJsonInternal(coreStatus);
+    }
+}

+ 108 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/mods/VCMIModsRepo.java

@@ -0,0 +1,108 @@
+package eu.vcmi.vcmi.mods;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.util.AsyncRequest;
+import eu.vcmi.vcmi.util.Log;
+import eu.vcmi.vcmi.util.ServerResponse;
+
+/**
+ * @author F
+ */
+public class VCMIModsRepo
+{
+    private final List<VCMIMod> mModsList;
+    private IOnModsRepoDownloaded mCallback;
+
+    public VCMIModsRepo()
+    {
+        mModsList = new ArrayList<>();
+    }
+
+    public void init(final String url, final IOnModsRepoDownloaded callback)
+    {
+        mCallback = callback;
+        new AsyncLoadRepo().execute(url);
+    }
+
+    public interface IOnModsRepoDownloaded
+    {
+        void onSuccess(ServerResponse<List<VCMIMod>> response);
+        void onError(final int code);
+    }
+
+    private class AsyncLoadRepo extends AsyncRequest<List<VCMIMod>>
+    {
+        @Override
+        protected ServerResponse<List<VCMIMod>> doInBackground(final String... params)
+        {
+            ServerResponse<List<VCMIMod>> serverResponse = sendRequest(params[0]);
+            if (serverResponse.isValid())
+            {
+                final List<VCMIMod> mods = new ArrayList<>();
+                try
+                {
+                    JSONObject jsonContent = new JSONObject(serverResponse.mRawContent);
+                    final JSONArray names = jsonContent.names();
+                    for (int i = 0; i < names.length(); ++i)
+                    {
+                        try
+                        {
+                            String name = names.getString(i);
+                            JSONObject modDownloadData = jsonContent.getJSONObject(name);
+
+                            if(modDownloadData.has("mod"))
+                            {
+                                String modFileAddress = modDownloadData.getString("mod");
+                                ServerResponse<List<VCMIMod>> modFile = sendRequest(modFileAddress);
+
+                                if (!modFile.isValid())
+                                {
+                                    continue;
+                                }
+
+                                JSONObject modJson = new JSONObject(modFile.mRawContent);
+                                mods.add(VCMIMod.buildFromRepoJson(name, modJson, modDownloadData));
+                            }
+                            else
+                            {
+                                mods.add(VCMIMod.buildFromRepoJson(name, modDownloadData, modDownloadData));
+                            }
+                        }
+                        catch (JSONException e)
+                        {
+                            Log.e(this, "Could not parse the response as json", e);
+                        }
+                    }
+                    serverResponse.mContent = mods;
+                }
+                catch (JSONException e)
+                {
+                    Log.e(this, "Could not parse the response as json", e);
+                    serverResponse.mCode = ServerResponse.LOCAL_ERROR_PARSING;
+                }
+            }
+            return serverResponse;
+        }
+
+        @Override
+        protected void onPostExecute(final ServerResponse<List<VCMIMod>> response)
+        {
+            if (response.isValid())
+            {
+                mModsList.clear();
+                mModsList.addAll(response.mContent);
+                mCallback.onSuccess(response);
+            }
+            else
+            {
+                mCallback.onError(response.mCode);
+            }
+        }
+    }
+}

+ 46 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiController.java

@@ -0,0 +1,46 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class AdventureAiController extends LauncherSettingWithDialogController<String, Config>
+{
+    public AdventureAiController(final AppCompatActivity activity)
+    {
+        super(activity);
+    }
+
+    @Override
+    protected LauncherSettingDialog<String> dialog()
+    {
+        return new AdventureAiSelectionDialog();
+    }
+
+    @Override
+    public void onItemChosen(final String item)
+    {
+        mConfig.setAdventureAi(item);
+        updateContent();
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_adventure_ai_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (mConfig == null)
+        {
+            return "";
+        }
+
+        return mConfig.getAdventureAi();
+    }
+}

+ 37 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/AdventureAiSelectionDialog.java

@@ -0,0 +1,37 @@
+package eu.vcmi.vcmi.settings;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class AdventureAiSelectionDialog extends LauncherSettingDialog<String>
+{
+    private static final List<String> AVAILABLE_AI = new ArrayList<>();
+
+    static
+    {
+        AVAILABLE_AI.add("VCAI");
+        AVAILABLE_AI.add("Nullkiller");
+    }
+
+    public AdventureAiSelectionDialog()
+    {
+        super(AVAILABLE_AI);
+    }
+
+    @Override
+    protected int dialogTitleResId()
+    {
+        return R.string.launcher_btn_adventure_ai_title;
+    }
+
+    @Override
+    protected CharSequence itemName(final String item)
+    {
+        return item;
+    }
+}

+ 48 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingController.java

@@ -0,0 +1,48 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class CodepageSettingController extends LauncherSettingWithDialogController<String, Config>
+{
+    public CodepageSettingController(final AppCompatActivity activity)
+    {
+        super(activity);
+    }
+
+    @Override
+    protected LauncherSettingDialog<String> dialog()
+    {
+        return new CodepageSettingDialog();
+    }
+
+    @Override
+    public void onItemChosen(final String item)
+    {
+        mConfig.updateCodepage(item);
+        updateContent();
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_cp_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (mConfig == null)
+        {
+            return "";
+        }
+        return mConfig.mCodepage == null || mConfig.mCodepage.isEmpty()
+               ? mActivity.getString(R.string.launcher_btn_cp_subtitle_unknown)
+               : mActivity.getString(R.string.launcher_btn_cp_subtitle, mConfig.mCodepage);
+    }
+}

+ 40 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CodepageSettingDialog.java

@@ -0,0 +1,40 @@
+package eu.vcmi.vcmi.settings;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class CodepageSettingDialog extends LauncherSettingDialog<String>
+{
+    private static final List<String> AVAILABLE_CODEPAGES = new ArrayList<>();
+
+    static
+    {
+        AVAILABLE_CODEPAGES.add("CP1250");
+        AVAILABLE_CODEPAGES.add("CP1251");
+        AVAILABLE_CODEPAGES.add("CP1252");
+        AVAILABLE_CODEPAGES.add("GBK");
+        AVAILABLE_CODEPAGES.add("GB2312");
+    }
+
+    public CodepageSettingDialog()
+    {
+        super(AVAILABLE_CODEPAGES);
+    }
+
+    @Override
+    protected int dialogTitleResId()
+    {
+        return R.string.launcher_btn_cp_title;
+    }
+
+    @Override
+    protected CharSequence itemName(final String item)
+    {
+        return item;
+    }
+}

+ 181 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/CopyDataController.java

@@ -0,0 +1,181 @@
+package eu.vcmi.vcmi.settings;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.view.View;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.loader.content.AsyncTaskLoader;
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.Storage;
+import eu.vcmi.vcmi.util.FileUtil;
+
+public class CopyDataController extends LauncherSettingController<Void, Void>
+{
+    public static final int PICK_EXTERNAL_VCMI_DATA_TO_COPY = 3;
+
+    private String progress;
+
+    public CopyDataController(final AppCompatActivity act)
+    {
+        super(act);
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_import_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (progress != null)
+        {
+            return progress;
+        }
+
+        return mActivity.getString(R.string.launcher_btn_import_description);
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+
+        intent.putExtra(
+                DocumentsContract.EXTRA_INITIAL_URI,
+                Uri.fromFile(new File(Environment.getExternalStorageDirectory(), "vcmi-data")));
+
+        mActivity.startActivityForResult(intent, PICK_EXTERNAL_VCMI_DATA_TO_COPY);
+    }
+
+    public void copyData(Uri folderToCopy)
+    {
+        AsyncCopyData copyTask = new AsyncCopyData(mActivity, folderToCopy);
+
+        copyTask.execute();
+    }
+
+    private class AsyncCopyData extends AsyncTask<String, String, Boolean>
+    {
+        private Activity owner;
+        private Uri folderToCopy;
+
+        public AsyncCopyData(Activity owner, Uri folderToCopy)
+        {
+            this.owner = owner;
+            this.folderToCopy = folderToCopy;
+        }
+
+        @Override
+        protected Boolean doInBackground(final String... params)
+        {
+            File targetDir = Storage.getVcmiDataDir(owner);
+            DocumentFile sourceDir = DocumentFile.fromTreeUri(owner, folderToCopy);
+
+            ArrayList<String> allowedFolders = new ArrayList<String>();
+
+            allowedFolders.add("Data");
+            allowedFolders.add("Mp3");
+            allowedFolders.add("Maps");
+            allowedFolders.add("Saves");
+            allowedFolders.add("Mods");
+            allowedFolders.add("config");
+
+            return copyDirectory(targetDir, sourceDir, allowedFolders);
+        }
+
+        @Override
+        protected void onPostExecute(Boolean result)
+        {
+            super.onPostExecute(result);
+
+            if (result)
+            {
+                CopyDataController.this.progress = null;
+                CopyDataController.this.updateContent();
+            }
+        }
+
+        @Override
+        protected void onProgressUpdate(String... values)
+        {
+            CopyDataController.this.progress = values[0];
+            CopyDataController.this.updateContent();
+        }
+
+        private boolean copyDirectory(File targetDir, DocumentFile sourceDir, List<String> allowed)
+        {
+            if (!targetDir.exists())
+            {
+                targetDir.mkdir();
+            }
+
+            for (DocumentFile child : sourceDir.listFiles())
+            {
+                if (allowed != null && !allowed.contains(child.getName()))
+                {
+                    continue;
+                }
+
+                File exported = new File(targetDir, child.getName());
+
+                if (child.isFile())
+                {
+                    publishProgress(owner.getString(R.string.launcher_progress_copy,
+                            child.getName()));
+
+                    if (!exported.exists())
+                    {
+                        try
+                        {
+                            exported.createNewFile();
+                        }
+                        catch (IOException e)
+                        {
+                            publishProgress("Failed to copy file " + child.getName());
+
+                            return false;
+                        }
+                    }
+
+                    try (
+                        final OutputStream targetStream = new FileOutputStream(exported, false);
+                        final InputStream sourceStream = owner.getContentResolver()
+                                .openInputStream(child.getUri()))
+                    {
+                        FileUtil.copyStream(sourceStream, targetStream);
+                    }
+                    catch (IOException e)
+                    {
+                        publishProgress("Failed to copy file " + child.getName());
+
+                        return false;
+                    }
+                }
+
+                if (child.isDirectory() && !copyDirectory(exported, child, null))
+                {
+                    return false;
+                }
+            }
+
+            return true;
+        }
+    }
+}
+

+ 19 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/DoubleConfig.java

@@ -0,0 +1,19 @@
+package eu.vcmi.vcmi.settings;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.util.SharedPrefs;
+
+/**
+ * @author F
+ */
+public class DoubleConfig
+{
+    public final Config mConfig;
+    public final SharedPrefs mPrefs;
+
+    public DoubleConfig(final Config config, final SharedPrefs prefs)
+    {
+        mConfig = config;
+        mPrefs = prefs;
+    }
+}

+ 168 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ExportDataController.java

@@ -0,0 +1,168 @@
+package eu.vcmi.vcmi.settings;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.AsyncTask;
+import android.os.Environment;
+import android.provider.DocumentsContract;
+import android.view.View;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.documentfile.provider.DocumentFile;
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.Storage;
+import eu.vcmi.vcmi.util.FileUtil;
+
+public class ExportDataController extends LauncherSettingController<Void, Void>
+{
+    public static final int PICK_DIRECTORY_TO_EXPORT = 4;
+
+    private String progress;
+
+    public ExportDataController(final AppCompatActivity act)
+    {
+        super(act);
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_export_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (progress != null)
+        {
+            return progress;
+        }
+
+        return mActivity.getString(R.string.launcher_btn_export_description);
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+
+        intent.putExtra(
+            DocumentsContract.EXTRA_INITIAL_URI,
+            Uri.fromFile(new File(Environment.getExternalStorageDirectory(), "vcmi-data")));
+
+        mActivity.startActivityForResult(intent, PICK_DIRECTORY_TO_EXPORT);
+    }
+
+    public void copyData(Uri targetFolder)
+    {
+        AsyncCopyData copyTask = new AsyncCopyData(mActivity, targetFolder);
+
+        copyTask.execute();
+    }
+
+    private class AsyncCopyData extends AsyncTask<String, String, Boolean>
+    {
+        private Activity owner;
+        private Uri targetFolder;
+
+        public AsyncCopyData(Activity owner, Uri targetFolder)
+        {
+            this.owner = owner;
+            this.targetFolder = targetFolder;
+        }
+
+        @Override
+        protected Boolean doInBackground(final String... params)
+        {
+            File targetDir = Storage.getVcmiDataDir(owner);
+            DocumentFile sourceDir = DocumentFile.fromTreeUri(owner, targetFolder);
+
+            return copyDirectory(targetDir, sourceDir);
+        }
+
+        @Override
+        protected void onPostExecute(Boolean result)
+        {
+            super.onPostExecute(result);
+
+            if (result)
+            {
+                ExportDataController.this.progress = null;
+                ExportDataController.this.updateContent();
+            }
+        }
+
+        @Override
+        protected void onProgressUpdate(String... values)
+        {
+            ExportDataController.this.progress = values[0];
+            ExportDataController.this.updateContent();
+        }
+
+        private boolean copyDirectory(File sourceDir, DocumentFile targetDir)
+        {
+            for (File child : sourceDir.listFiles())
+            {
+                DocumentFile exported = targetDir.findFile(child.getName());
+
+                if (child.isFile())
+                {
+                    publishProgress(owner.getString(R.string.launcher_progress_copy,
+                            child.getName()));
+
+                    if (exported == null)
+                    {
+                        try
+                        {
+                            exported = targetDir.createFile(
+                                "application/octet-stream",
+                                child.getName());
+                        }
+                        catch (UnsupportedOperationException e)
+                        {
+                            publishProgress("Failed to copy file " + child.getName());
+
+                            return false;
+                        }
+                    }
+
+                    try(
+                            final OutputStream targetStream = owner.getContentResolver()
+                                    .openOutputStream(exported.getUri());
+                            final InputStream sourceStream = new FileInputStream(child))
+                    {
+                        FileUtil.copyStream(sourceStream, targetStream);
+                    }
+                    catch (IOException e)
+                    {
+                        publishProgress("Failed to copy file " + child.getName());
+
+                        return false;
+                    }
+                }
+
+                if (child.isDirectory())
+                {
+                    if (exported == null)
+                    {
+                        exported = targetDir.createDirectory(child.getName());
+                    }
+
+                    if(!copyDirectory(child, exported))
+                    {
+                        return false;
+                    }
+                }
+            }
+
+            return true;
+        }
+    }
+}

+ 75 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingController.java

@@ -0,0 +1,75 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.View;
+import android.widget.TextView;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public abstract class LauncherSettingController<TSetting, TConf> implements View.OnClickListener
+{
+    protected AppCompatActivity mActivity;
+    protected TConf mConfig;
+    private View mSettingViewRoot;
+    private TextView mSettingsTextMain;
+    private TextView mSettingsTextSub;
+
+    LauncherSettingController(final AppCompatActivity act)
+    {
+        mActivity = act;
+    }
+
+    public final LauncherSettingController<TSetting, TConf> init(final int rootViewResId)
+    {
+        return init(rootViewResId, null);
+    }
+
+    public final LauncherSettingController<TSetting, TConf> init(final int rootViewResId, final TConf config)
+    {
+        mSettingViewRoot = mActivity.findViewById(rootViewResId);
+        mSettingViewRoot.setOnClickListener(this);
+        mSettingsTextMain = (TextView) mSettingViewRoot.findViewById(R.id.inc_launcher_btn_main);
+        mSettingsTextSub = (TextView) mSettingViewRoot.findViewById(R.id.inc_launcher_btn_sub);
+        childrenInit(mSettingViewRoot);
+        updateConfig(config);
+        updateContent();
+        return this;
+    }
+
+    protected void childrenInit(final View root)
+    {
+
+    }
+
+    public void updateConfig(final TConf conf)
+    {
+        mConfig = conf;
+        updateContent();
+    }
+
+    public void updateContent()
+    {
+        mSettingsTextMain.setText(mainText());
+        if (mSettingsTextSub != null)
+        {
+            mSettingsTextSub.setText(subText());
+        }
+    }
+
+    protected abstract String mainText();
+
+    protected abstract String subText();
+
+    public void show()
+    {
+        mSettingViewRoot.setVisibility(View.VISIBLE);
+    }
+
+    public void hide()
+    {
+        mSettingViewRoot.setVisibility(View.GONE);
+    }
+}

+ 73 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingDialog.java

@@ -0,0 +1,73 @@
+package eu.vcmi.vcmi.settings;
+
+import android.app.Dialog;
+import android.content.DialogInterface;
+import android.os.Bundle;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+import androidx.appcompat.app.AlertDialog;
+
+import java.util.ArrayList;
+
+import java.util.List;
+
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public abstract class LauncherSettingDialog<T> extends DialogFragment
+{
+    protected final List<T> mDataset;
+    private IOnItemChosen<T> mObserver;
+
+    protected LauncherSettingDialog(final List<T> dataset)
+    {
+        mDataset = dataset;
+    }
+
+    public void observe(final IOnItemChosen<T> observer)
+    {
+        mObserver = observer;
+    }
+
+    protected abstract CharSequence itemName(T item);
+
+    protected abstract int dialogTitleResId();
+
+    @NonNull
+    @Override
+    public Dialog onCreateDialog(final Bundle savedInstanceState)
+    {
+        List<CharSequence> list = new ArrayList<>();
+
+        for (T t : mDataset)
+        {
+            CharSequence charSequence = itemName(t);
+            list.add(charSequence);
+        }
+
+        return new AlertDialog.Builder(getActivity())
+            .setTitle(dialogTitleResId())
+            .setItems(
+                    list.toArray(new CharSequence[0]),
+                    this::onItemChosenInternal)
+            .create();
+    }
+
+    private void onItemChosenInternal(final DialogInterface dialog, final int index)
+    {
+        final T chosenItem = mDataset.get(index);
+        Log.d(this, "Chosen item: " + chosenItem);
+        dialog.dismiss();
+        if (mObserver != null)
+        {
+            mObserver.onItemChosen(chosenItem);
+        }
+    }
+
+    public interface IOnItemChosen<V>
+    {
+        void onItemChosen(V item);
+    }
+}

+ 31 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithDialogController.java

@@ -0,0 +1,31 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.View;
+
+import eu.vcmi.vcmi.util.Log;
+
+/**
+ * @author F
+ */
+public abstract class LauncherSettingWithDialogController<T, Conf> extends LauncherSettingController<T, Conf>
+    implements LauncherSettingDialog.IOnItemChosen<T>
+{
+    public static final String SETTING_DIALOG_ID = "settings.dialog";
+
+    protected LauncherSettingWithDialogController(final AppCompatActivity act)
+    {
+        super(act);
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        Log.i(this, "Showing dialog");
+        final LauncherSettingDialog<T> dialog = dialog();
+        dialog.observe(this); // TODO rebinding dialogs on activity config changes
+        dialog.show(mActivity.getSupportFragmentManager(), SETTING_DIALOG_ID);
+    }
+
+    protected abstract LauncherSettingDialog<T> dialog();
+}

+ 83 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/LauncherSettingWithSliderController.java

@@ -0,0 +1,83 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.appcompat.widget.AppCompatSeekBar;
+import android.view.View;
+import android.widget.SeekBar;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public abstract class LauncherSettingWithSliderController<T, Conf> extends LauncherSettingController<T, Conf>
+{
+    private AppCompatSeekBar mSlider;
+    private final int mSliderMin;
+    private final int mSliderMax;
+
+    protected LauncherSettingWithSliderController(final AppCompatActivity act, final int min, final int max)
+    {
+        super(act);
+        mSliderMin = min;
+        mSliderMax = max;
+    }
+
+    @Override
+    protected void childrenInit(final View root)
+    {
+        mSlider = (AppCompatSeekBar) root.findViewById(R.id.inc_launcher_btn_slider);
+        if (mSliderMax <= mSliderMin)
+        {
+            throw new IllegalArgumentException("slider min>=max");
+        }
+        mSlider.setMax(mSliderMax - mSliderMin);
+        mSlider.setOnSeekBarChangeListener(new OnValueChangedListener());
+    }
+
+    protected abstract void onValueChanged(final int v);
+    protected abstract int currentValue();
+
+    @Override
+    public void updateContent()
+    {
+        super.updateContent();
+        mSlider.setProgress(currentValue() + mSliderMin);
+    }
+
+    @Override
+    protected String subText()
+    {
+        return null; // not used with slider settings
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        // not used with slider settings
+    }
+
+    private class OnValueChangedListener implements SeekBar.OnSeekBarChangeListener
+    {
+        @Override
+        public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser)
+        {
+            if (fromUser)
+            {
+                onValueChanged(progress);
+            }
+        }
+
+        @Override
+        public void onStartTrackingTouch(final SeekBar seekBar)
+        {
+
+        }
+
+        @Override
+        public void onStopTrackingTouch(final SeekBar seekBar)
+        {
+
+        }
+    }
+}

+ 38 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ModsBtnController.java

@@ -0,0 +1,38 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.View;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class ModsBtnController extends LauncherSettingController<Void, Void>
+{
+    private View.OnClickListener mOnSelectedAction;
+
+    public ModsBtnController(final AppCompatActivity act, final View.OnClickListener onSelectedAction)
+    {
+        super(act);
+        mOnSelectedAction = onSelectedAction;
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_mods_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        return mActivity.getString(R.string.launcher_btn_mods_subtitle);
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        mOnSelectedAction.onClick(v);
+    }
+}

+ 40 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/MusicSettingController.java

@@ -0,0 +1,40 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class MusicSettingController extends LauncherSettingWithSliderController<Integer, Config>
+{
+    public MusicSettingController(final AppCompatActivity act)
+    {
+        super(act, 0, 100);
+    }
+
+    @Override
+    protected void onValueChanged(final int v)
+    {
+        mConfig.updateMusic(v);
+        updateContent();
+    }
+
+    @Override
+    protected int currentValue()
+    {
+        if (mConfig == null)
+        {
+            return Config.DEFAULT_MUSIC_VALUE;
+        }
+        return mConfig.mVolumeMusic;
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_music_title);
+    }
+}

+ 75 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingController.java

@@ -0,0 +1,75 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class PointerModeSettingController
+    extends LauncherSettingWithDialogController<PointerModeSettingController.PointerMode, Config>
+{
+    public PointerModeSettingController(final AppCompatActivity activity)
+    {
+        super(activity);
+    }
+
+    @Override
+    protected LauncherSettingDialog<PointerMode> dialog()
+    {
+        return new PointerModeSettingDialog();
+    }
+
+    @Override
+    public void onItemChosen(final PointerMode item)
+    {
+        mConfig.setPointerMode(item == PointerMode.RELATIVE);
+        mConfig.updateSwipe(item.supportsSwipe());
+        updateContent();
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_pointermode_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (mConfig == null)
+        {
+            return "";
+        }
+        return mActivity.getString(R.string.launcher_btn_pointermode_subtitle,
+            PointerModeSettingDialog.pointerModeToUserString(mActivity, getPointerMode()));
+    }
+
+    private PointerMode getPointerMode()
+    {
+        if(mConfig.getPointerModeIsRelative())
+        {
+            return PointerMode.RELATIVE;
+        }
+
+        if(mConfig.mSwipeEnabled)
+        {
+            return PointerMode.NORMAL_WITH_SWIPE;
+        }
+
+        return PointerMode.NORMAL;
+    }
+
+    public enum PointerMode
+    {
+        NORMAL,
+        NORMAL_WITH_SWIPE,
+        RELATIVE;
+
+        public boolean supportsSwipe()
+        {
+            return this == NORMAL_WITH_SWIPE;
+        }
+    }
+}

+ 58 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerModeSettingDialog.java

@@ -0,0 +1,58 @@
+package eu.vcmi.vcmi.settings;
+
+import android.content.Context;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class PointerModeSettingDialog extends LauncherSettingDialog<PointerModeSettingController.PointerMode>
+{
+    private static final List<PointerModeSettingController.PointerMode> POINTER_MODES = new ArrayList<>();
+
+    static
+    {
+        POINTER_MODES.add(PointerModeSettingController.PointerMode.NORMAL);
+        POINTER_MODES.add(PointerModeSettingController.PointerMode.NORMAL_WITH_SWIPE);
+        POINTER_MODES.add(PointerModeSettingController.PointerMode.RELATIVE);
+    }
+
+    public PointerModeSettingDialog()
+    {
+        super(POINTER_MODES);
+    }
+
+    public static String pointerModeToUserString(
+            final Context ctx,
+            final PointerModeSettingController.PointerMode pointerMode)
+    {
+        switch (pointerMode)
+        {
+            default:
+                return "";
+            case NORMAL:
+                return ctx.getString(R.string.misc_pointermode_normal);
+            case NORMAL_WITH_SWIPE:
+                return ctx.getString(R.string.misc_pointermode_swipe);
+            case RELATIVE:
+                return ctx.getString(R.string.misc_pointermode_relative);
+        }
+    }
+
+    @Override
+    protected int dialogTitleResId()
+    {
+        return R.string.launcher_btn_pointermode_title;
+    }
+
+    @Override
+    protected CharSequence itemName(final PointerModeSettingController.PointerMode item)
+    {
+        return pointerModeToUserString(getContext(), item);
+    }
+
+}

+ 51 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingController.java

@@ -0,0 +1,51 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class PointerMultiplierSettingController
+        extends LauncherSettingWithDialogController<Float, Config>
+{
+    public PointerMultiplierSettingController(final AppCompatActivity activity)
+    {
+        super(activity);
+    }
+
+    @Override
+    protected LauncherSettingDialog<Float> dialog()
+    {
+        return new PointerMultiplierSettingDialog();
+    }
+
+    @Override
+    public void onItemChosen(final Float item)
+    {
+        mConfig.setPointerSpeedMultiplier(item);
+        updateContent();
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_pointermulti_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (mConfig == null)
+        {
+            return "";
+        }
+
+        String pointerModeString = PointerMultiplierSettingDialog.pointerMultiplierToUserString(
+                mConfig.getPointerSpeedMultiplier());
+
+        return mActivity.getString(R.string.launcher_btn_pointermulti_subtitle, pointerModeString);
+    }
+}

+ 48 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/PointerMultiplierSettingDialog.java

@@ -0,0 +1,48 @@
+package eu.vcmi.vcmi.settings;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class PointerMultiplierSettingDialog extends LauncherSettingDialog<Float>
+{
+    private static final List<Float> AVAILABLE_MULTIPLIERS = new ArrayList<>();
+
+    static
+    {
+        AVAILABLE_MULTIPLIERS.add(1.0f);
+        AVAILABLE_MULTIPLIERS.add(1.25f);
+        AVAILABLE_MULTIPLIERS.add(1.5f);
+        AVAILABLE_MULTIPLIERS.add(1.75f);
+        AVAILABLE_MULTIPLIERS.add(2.0f);
+        AVAILABLE_MULTIPLIERS.add(2.5f);
+        AVAILABLE_MULTIPLIERS.add(3.0f);
+    }
+
+    public PointerMultiplierSettingDialog()
+    {
+        super(AVAILABLE_MULTIPLIERS);
+    }
+
+    @Override
+    protected int dialogTitleResId()
+    {
+        return R.string.launcher_btn_pointermode_title;
+    }
+
+    @Override
+    protected CharSequence itemName(final Float item)
+    {
+        return pointerMultiplierToUserString(item);
+    }
+
+    public static String pointerMultiplierToUserString(final float multiplier)
+    {
+        return String.format(Locale.US, "%.2fx", multiplier);
+    }
+}

+ 66 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingController.java

@@ -0,0 +1,66 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class ScreenResSettingController extends LauncherSettingWithDialogController<ScreenResSettingController.ScreenRes, Config>
+{
+    public ScreenResSettingController(final AppCompatActivity activity)
+    {
+        super(activity);
+    }
+
+    @Override
+    protected LauncherSettingDialog<ScreenRes> dialog()
+    {
+        return new ScreenResSettingDialog(mActivity);
+    }
+
+    @Override
+    public void onItemChosen(final ScreenRes item)
+    {
+        mConfig.updateResolution(item.mWidth, item.mHeight);
+        updateContent();
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_res_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        if (mConfig == null)
+        {
+            return "";
+        }
+        return mConfig.mResolutionWidth <= 0 || mConfig.mResolutionHeight <= 0
+               ? mActivity.getString(R.string.launcher_btn_res_subtitle_unknown)
+               : mActivity.getString(R.string.launcher_btn_res_subtitle, mConfig.mResolutionWidth, mConfig.mResolutionHeight);
+    }
+
+    public static class ScreenRes
+    {
+        public int mWidth;
+        public int mHeight;
+
+        public ScreenRes(final int width, final int height)
+        {
+            mWidth = width;
+            mHeight = height;
+        }
+
+        @Override
+        public String toString()
+        {
+            return mWidth + "x" + mHeight;
+        }
+    }
+}

+ 104 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/ScreenResSettingDialog.java

@@ -0,0 +1,104 @@
+package eu.vcmi.vcmi.settings;
+
+import android.app.Activity;
+
+import org.json.JSONArray;
+import org.json.JSONObject;
+import java.io.File;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Queue;
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.Storage;
+import eu.vcmi.vcmi.util.FileUtil;
+
+/**
+ * @author F
+ */
+public class ScreenResSettingDialog extends LauncherSettingDialog<ScreenResSettingController.ScreenRes>
+{
+    public ScreenResSettingDialog(Activity mActivity)
+    {
+        super(loadResolutions(mActivity));
+    }
+
+    @Override
+    protected int dialogTitleResId()
+    {
+        return R.string.launcher_btn_res_title;
+    }
+
+    @Override
+    protected CharSequence itemName(final ScreenResSettingController.ScreenRes item)
+    {
+        return item.toString();
+    }
+
+    private static List<ScreenResSettingController.ScreenRes> loadResolutions(Activity activity)
+    {
+        List<ScreenResSettingController.ScreenRes> availableResolutions = new ArrayList<>();
+
+        try
+        {
+            File modsFolder = new File(Storage.getVcmiDataDir(activity), "Mods");
+            Queue<File> folders = new ArrayDeque<File>();
+            folders.offer(modsFolder);
+
+            while (!folders.isEmpty())
+            {
+                File folder = folders.poll();
+                File[] children = folder.listFiles();
+
+                if(children == null) continue;
+
+                for (File child : children)
+                {
+                    if (child.isDirectory())
+                    {
+                        folders.add(child);
+                    }
+                    else if (child.getName().equals("resolutions.json"))
+                    {
+                        JSONArray resolutions = new JSONObject(FileUtil.read(child))
+                                .getJSONArray("GUISettings");
+
+                        for(int index = 0; index < resolutions.length(); index++)
+                        {
+                            try
+                            {
+                                JSONObject resolution = resolutions
+                                        .getJSONObject(index)
+                                        .getJSONObject("resolution");
+
+                                availableResolutions.add(new ScreenResSettingController.ScreenRes(
+                                        resolution.getInt("x"),
+                                        resolution.getInt("y")
+                                ));
+                            }
+                            catch (Exception ex)
+                            {
+                                ex.printStackTrace();
+                            }
+                        }
+                    }
+                }
+            }
+
+            if(availableResolutions.isEmpty())
+            {
+                availableResolutions.add(new ScreenResSettingController.ScreenRes(800, 600));
+            }
+        }
+        catch(Exception ex)
+        {
+            ex.printStackTrace();
+
+            availableResolutions.clear();
+
+            availableResolutions.add(new ScreenResSettingController.ScreenRes(800, 600));
+        }
+
+        return availableResolutions;
+    }
+}

+ 40 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/SoundSettingController.java

@@ -0,0 +1,40 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+
+import eu.vcmi.vcmi.Config;
+import eu.vcmi.vcmi.R;
+
+/**
+ * @author F
+ */
+public class SoundSettingController extends LauncherSettingWithSliderController<Integer, Config>
+{
+    public SoundSettingController(final AppCompatActivity act)
+    {
+        super(act, 0, 100);
+    }
+
+    @Override
+    protected void onValueChanged(final int v)
+    {
+        mConfig.updateSound(v);
+        updateContent();
+    }
+
+    @Override
+    protected int currentValue()
+    {
+        if (mConfig == null)
+        {
+            return Config.DEFAULT_SOUND_VALUE;
+        }
+        return mConfig.mVolumeSound;
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_sound_title);
+    }
+}

+ 39 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/settings/StartGameController.java

@@ -0,0 +1,39 @@
+package eu.vcmi.vcmi.settings;
+
+import androidx.appcompat.app.AppCompatActivity;
+import android.view.View;
+
+import eu.vcmi.vcmi.R;
+import eu.vcmi.vcmi.util.GeneratedVersion;
+
+/**
+ * @author F
+ */
+public class StartGameController extends LauncherSettingController<Void, Void>
+{
+    private View.OnClickListener mOnSelectedAction;
+
+    public StartGameController(final AppCompatActivity act, final View.OnClickListener onSelectedAction)
+    {
+        super(act);
+        mOnSelectedAction = onSelectedAction;
+    }
+
+    @Override
+    protected String mainText()
+    {
+        return mActivity.getString(R.string.launcher_btn_start_title);
+    }
+
+    @Override
+    protected String subText()
+    {
+        return mActivity.getString(R.string.launcher_btn_start_subtitle, GeneratedVersion.VCMI_VERSION);
+    }
+
+    @Override
+    public void onClick(final View v)
+    {
+        mOnSelectedAction.onClick(v);
+    }
+}

+ 49 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/AsyncRequest.java

@@ -0,0 +1,49 @@
+package eu.vcmi.vcmi.util;
+
+import android.annotation.TargetApi;
+import android.os.AsyncTask;
+import android.os.Build;
+import androidx.annotation.RequiresApi;
+
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.Scanner;
+
+import eu.vcmi.vcmi.Const;
+
+/**
+ * @author F
+ */
+public abstract class AsyncRequest<T> extends AsyncTask<String, Void, ServerResponse<T>>
+{
+    @TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
+    protected ServerResponse<T> sendRequest(final String url)
+    {
+
+        try
+        {
+            final HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+            final int responseCode = conn.getResponseCode();
+            if (!ServerResponse.isResponseCodeValid(responseCode))
+            {
+                return new ServerResponse<>(responseCode, null);
+            }
+
+            try (Scanner s = new java.util.Scanner(conn.getInputStream()).useDelimiter("\\A"))
+            {
+                final String response = s.hasNext() ? s.next() : "";
+                return new ServerResponse<>(responseCode, response);
+            }
+            catch (final Exception e)
+            {
+                Log.e(this, "Request failed: ", e);
+            }
+        }
+        catch (final Exception e)
+        {
+            Log.e(this, "Request failed: ", e);
+        }
+        return new ServerResponse<>(ServerResponse.LOCAL_ERROR_IO, null);
+    }
+
+}

+ 344 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/FileUtil.java

@@ -0,0 +1,344 @@
+package eu.vcmi.vcmi.util;
+
+import android.annotation.TargetApi;
+import android.content.res.AssetManager;
+import android.os.Environment;
+import android.text.TextUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.charset.Charset;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+import java.util.zip.ZipInputStream;
+
+import eu.vcmi.vcmi.Const;
+
+/**
+ * @author F
+ */
+@TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
+public class FileUtil
+{
+    private static final int BUFFER_SIZE = 4096;
+
+    public static String read(final InputStream stream) throws IOException
+    {
+        try (InputStreamReader reader = new InputStreamReader(stream))
+        {
+            return readInternal(reader);
+        }
+    }
+
+    public static String read(final File file) throws IOException
+    {
+        try (FileReader reader = new FileReader(file))
+        {
+            return readInternal(reader);
+        }
+        catch (final FileNotFoundException ignored)
+        {
+            Log.w("Could not load file: " + file);
+            return null;
+        }
+    }
+
+    private static String readInternal(final InputStreamReader reader) throws IOException
+    {
+        final char[] buffer = new char[BUFFER_SIZE];
+        int currentRead;
+        final StringBuilder content = new StringBuilder();
+        while ((currentRead = reader.read(buffer, 0, BUFFER_SIZE)) >= 0)
+        {
+            content.append(buffer, 0, currentRead);
+        }
+        return content.toString();
+    }
+
+    public static void write(final File file, final String data) throws IOException
+    {
+        if (!ensureWriteable(file))
+        {
+            Log.e("Couldn't write " + data + " to " + file);
+            return;
+        }
+        try (final FileWriter fw = new FileWriter(file, false))
+        {
+            Log.v(null, "Saving data: " + data + " to " + file.getAbsolutePath());
+            fw.write(data);
+        }
+    }
+
+    private static boolean ensureWriteable(final File file)
+    {
+        if (file == null)
+        {
+            Log.e("Broken path given to fileutil");
+            return false;
+        }
+
+        final File dir = file.getParentFile();
+
+        if (dir.exists() || dir.mkdirs())
+        {
+            return true;
+        }
+
+        Log.e("Couldn't create dir " + dir);
+
+        return false;
+    }
+
+    public static boolean clearDirectory(final File dir)
+    {
+        for (final File f : dir.listFiles())
+        {
+            if (f.isDirectory() && !clearDirectory(f))
+            {
+                return false;
+            }
+
+            if (!f.delete())
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    public static void copyDir(final File srcFile, final File dstFile)
+    {
+        File[] files = srcFile.listFiles();
+
+        if(!dstFile.exists()) dstFile.mkdir();
+
+        if(files == null)
+            return;
+
+        for (File child : files){
+            File childTarget = new File(dstFile, child.getName());
+
+            if(child.isDirectory()){
+                copyDir(child, childTarget);
+            }
+            else{
+                copyFile(child, childTarget);
+            }
+        }
+    }
+
+    public static boolean copyFile(final File srcFile, final File dstFile)
+    {
+        if (!srcFile.exists())
+        {
+            return false;
+        }
+
+        final File dstDir = dstFile.getParentFile();
+        if (!dstDir.exists())
+        {
+            if (!dstDir.mkdirs())
+            {
+                Log.w("Couldn't create dir to copy file: " + dstFile);
+                return false;
+            }
+        }
+
+        try (final FileInputStream input = new FileInputStream(srcFile);
+             final FileOutputStream output = new FileOutputStream(dstFile))
+        {
+            copyStream(input, output);
+            return true;
+        }
+        catch (final Exception ex)
+        {
+            Log.e("Couldn't copy " + srcFile + " to " + dstFile, ex);
+            return false;
+        }
+    }
+
+    public static void copyStream(InputStream source, OutputStream target) throws IOException
+    {
+        final byte[] buffer = new byte[BUFFER_SIZE];
+        int read;
+        while ((read = source.read(buffer)) != -1)
+        {
+            target.write(buffer, 0, read);
+        }
+    }
+
+    // (when internal data have changed)
+    public static boolean reloadVcmiDataToInternalDir(final File vcmiInternalDir, final AssetManager assets)
+    {
+        return clearDirectory(vcmiInternalDir) && unpackVcmiDataToInternalDir(vcmiInternalDir, assets);
+    }
+
+    public static boolean unpackVcmiDataToInternalDir(final File vcmiInternalDir, final AssetManager assets)
+    {
+        try
+        {
+            final InputStream inputStream = assets.open("internalData.zip");
+            final boolean success = unpackZipFile(inputStream, vcmiInternalDir, null);
+            inputStream.close();
+            return success;
+        }
+        catch (final Exception e)
+        {
+            Log.e("Couldn't extract vcmi data to internal dir", e);
+            return false;
+        }
+    }
+
+    public static boolean unpackZipFile(
+            final File inputFile,
+            final File destDir,
+            IZipProgressReporter progressReporter)
+    {
+        try
+        {
+            final InputStream inputStream = new FileInputStream(inputFile);
+            final boolean success = unpackZipFile(
+                    inputStream,
+                    destDir,
+                    progressReporter);
+
+            inputStream.close();
+
+            return success;
+        }
+        catch (final Exception e)
+        {
+            Log.e("Couldn't extract file to " + destDir, e);
+            return false;
+        }
+    }
+
+    public static int countFilesInZip(final File zipFile)
+    {
+        int totalEntries = 0;
+
+        try
+        {
+            final InputStream inputStream = new FileInputStream(zipFile);
+            ZipInputStream is = new ZipInputStream(inputStream);
+            ZipEntry zipEntry;
+
+            while ((zipEntry = is.getNextEntry()) != null)
+            {
+                totalEntries++;
+            }
+
+            is.closeEntry();
+            is.close();
+            inputStream.close();
+        }
+        catch (final Exception e)
+        {
+            Log.e("Couldn't count items in zip", e);
+        }
+
+        return totalEntries;
+    }
+
+    public static boolean unpackZipFile(
+        final InputStream inputStream,
+        final File destDir,
+        final IZipProgressReporter progressReporter)
+    {
+        try
+        {
+            int unpackedEntries = 0;
+            final byte[] buffer = new byte[BUFFER_SIZE];
+
+            ZipInputStream is = new ZipInputStream(inputStream);
+            ZipEntry zipEntry;
+
+            while ((zipEntry = is.getNextEntry()) != null)
+            {
+                final String fileName = zipEntry.getName();
+                final File newFile = new File(destDir, fileName);
+
+                if (newFile.exists())
+                {
+                    Log.d("Already exists: " + newFile.getName());
+                    continue;
+                }
+                else if (zipEntry.isDirectory())
+                {
+                    Log.v("Creating new dir: " + zipEntry);
+                    if (!newFile.mkdirs())
+                    {
+                        Log.e("Couldn't create directory " + newFile.getAbsolutePath());
+                        return false;
+                    }
+                    continue;
+                }
+
+                final File parentFile = new File(newFile.getParent());
+                if (!parentFile.exists() && !parentFile.mkdirs())
+                {
+                    Log.e("Couldn't create directory " + parentFile.getAbsolutePath());
+                    return false;
+                }
+
+                final FileOutputStream fos = new FileOutputStream(newFile, false);
+
+                int currentRead;
+                while ((currentRead = is.read(buffer)) > 0)
+                {
+                    fos.write(buffer, 0, currentRead);
+                }
+
+                fos.flush();
+                fos.close();
+                ++unpackedEntries;
+
+                if(progressReporter != null)
+                {
+                    progressReporter.onUnpacked(newFile);
+                }
+            }
+            Log.d("Unpacked data (" + unpackedEntries + " entries)");
+
+            is.closeEntry();
+            is.close();
+            return true;
+        }
+        catch (final Exception e)
+        {
+            Log.e("Couldn't extract vcmi data to " + destDir, e);
+            return false;
+        }
+    }
+
+    public static String configFileLocation(File filesDir)
+    {
+        return filesDir + "/config/settings.json";
+    }
+
+    public static String readAssetsStream(final AssetManager assets, final String assetPath)
+    {
+        if (assets == null || TextUtils.isEmpty(assetPath))
+        {
+            return null;
+        }
+
+        try (java.util.Scanner s = new java.util.Scanner(assets.open(assetPath), "UTF-8").useDelimiter("\\A"))
+        {
+            return s.hasNext() ? s.next() : null;
+        }
+        catch (final IOException e)
+        {
+            Log.e("Couldn't read stream data", e);
+            return null;
+        }
+    }
+}

+ 8 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/IZipProgressReporter.java

@@ -0,0 +1,8 @@
+package eu.vcmi.vcmi.util;
+
+import java.io.File;
+
+public interface IZipProgressReporter
+{
+    void onUnpacked(File newFile);
+}

+ 198 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/InstallModAsync.java

@@ -0,0 +1,198 @@
+package eu.vcmi.vcmi.util;
+
+import android.content.Context;
+import android.nfc.FormatException;
+import android.os.AsyncTask;
+import android.os.Build;
+import android.util.Log;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.FileFilter;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLConnection;
+
+public class InstallModAsync
+        extends AsyncTask<String, String, Boolean>
+        implements IZipProgressReporter
+{
+    private static final String TAG = "DOWNLOADFILE";
+    private static final int DOWNLOAD_PERCENT = 70;
+
+    private PostDownload callback;
+    private File downloadLocation;
+    private File extractLocation;
+    private Context context;
+    private int totalFiles;
+    private int unpackedFiles;
+
+    public InstallModAsync(File extractLocation, Context context, PostDownload callback)
+    {
+        this.context = context;
+        this.callback = callback;
+        this.extractLocation = extractLocation;
+    }
+
+    @Override
+    protected Boolean doInBackground(String... args)
+    {
+        int count;
+
+        try
+        {
+            File modsFolder = extractLocation.getParentFile();
+
+            if (!modsFolder.exists()) modsFolder.mkdir();
+
+            this.downloadLocation = File.createTempFile("tmp", ".zip", modsFolder);
+
+            URL url = new URL(args[0]);
+            URLConnection connection = url.openConnection();
+            connection.connect();
+
+            long lengthOfFile = -1;
+
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
+            {
+                lengthOfFile = connection.getContentLengthLong();
+            }
+
+            if(lengthOfFile == -1)
+            {
+                try
+                {
+                    lengthOfFile = Long.parseLong(connection.getHeaderField("Content-Length"));
+                    Log.d(TAG, "Length of the file: " + lengthOfFile);
+                } catch (NumberFormatException ex)
+                {
+                    Log.d(TAG, "Failed to parse content length", ex);
+                }
+            }
+
+            if(lengthOfFile == -1)
+            {
+                lengthOfFile = 100000000;
+                Log.d(TAG, "Using dummy length of file");
+            }
+
+            InputStream input = new BufferedInputStream(url.openStream());
+            FileOutputStream output = new FileOutputStream(downloadLocation); //context.openFileOutput("content.zip", Context.MODE_PRIVATE);
+            Log.d(TAG, "file saved at " + downloadLocation.getAbsolutePath());
+
+            byte data[] = new byte[1024];
+            long total = 0;
+            while ((count = input.read(data)) != -1)
+            {
+                total += count;
+                output.write(data, 0, count);
+                this.publishProgress((int) ((total * DOWNLOAD_PERCENT) / lengthOfFile) + "%");
+            }
+
+            output.flush();
+            output.close();
+            input.close();
+
+            File tempDir = File.createTempFile("tmp", "", modsFolder);
+
+            tempDir.delete();
+            tempDir.mkdir();
+
+            if (!extractLocation.exists()) extractLocation.mkdir();
+
+            try
+            {
+                totalFiles = FileUtil.countFilesInZip(downloadLocation);
+                unpackedFiles = 0;
+
+                FileUtil.unpackZipFile(downloadLocation, tempDir, this);
+
+                return moveModToExtractLocation(tempDir);
+            }
+            finally
+            {
+                downloadLocation.delete();
+                FileUtil.clearDirectory(tempDir);
+                tempDir.delete();
+            }
+        } catch (Exception e)
+        {
+            Log.e(TAG, "Unhandled exception while installing mod", e);
+        }
+
+        return false;
+    }
+
+    @Override
+    protected void onProgressUpdate(String... values)
+    {
+        callback.downloadProgress(values);
+    }
+
+    @Override
+    protected void onPostExecute(Boolean result)
+    {
+        if (callback != null) callback.downloadDone(result, extractLocation);
+    }
+
+    private boolean moveModToExtractLocation(File tempDir)
+    {
+        return moveModToExtractLocation(tempDir, 0);
+    }
+
+    private boolean moveModToExtractLocation(File tempDir, int level)
+    {
+        File[] modJson = tempDir.listFiles(new FileFilter()
+        {
+            @Override
+            public boolean accept(File file)
+            {
+                return file.getName().equalsIgnoreCase("Mod.json");
+            }
+        });
+
+        if (modJson != null && modJson.length > 0)
+        {
+            File modFolder = modJson[0].getParentFile();
+
+            if (!modFolder.renameTo(extractLocation))
+            {
+                FileUtil.copyDir(modFolder, extractLocation);
+            }
+
+            return true;
+        }
+
+        if (level <= 1)
+        {
+            for (File child : tempDir.listFiles())
+            {
+                if (child.isDirectory() && moveModToExtractLocation(child, level + 1))
+                {
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    @Override
+    public void onUnpacked(File newFile)
+    {
+        unpackedFiles++;
+
+        int progress = DOWNLOAD_PERCENT
+                + (unpackedFiles * (100 - DOWNLOAD_PERCENT) / totalFiles);
+
+        publishProgress(progress + "%");
+    }
+
+    public interface PostDownload
+    {
+        void downloadDone(Boolean succeed, File modFolder);
+
+        void downloadProgress(String... progress);
+    }
+}

+ 100 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LegacyConfigReader.java

@@ -0,0 +1,100 @@
+package eu.vcmi.vcmi.util;
+
+import android.annotation.TargetApi;
+import android.text.TextUtils;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.ObjectInputStream;
+
+import eu.vcmi.vcmi.Const;
+
+/**
+ * helper used to retrieve old vcmi config (currently only needed for h3 data path in order to migrate the data to the new location)
+ *
+ * @author F
+ */
+public final class LegacyConfigReader
+{
+    private static void skipBools(final ObjectInputStream stream, final int num) throws IOException
+    {
+        for (int i = 0; i < num; ++i)
+        {
+            stream.readBoolean();
+        }
+    }
+
+    private static void skipInts(final ObjectInputStream stream, final int num) throws IOException
+    {
+        for (int i = 0; i < num; ++i)
+        {
+            stream.readInt();
+        }
+    }
+
+    @TargetApi(Const.SUPPRESS_TRY_WITH_RESOURCES_WARNING)
+    public static Config load(final File basePath)
+    {
+        final File settingsFile = new File(basePath, "/libsdl-settings.cfg");
+        if (!settingsFile.exists())
+        {
+            Log.i("Legacy config file doesn't exist");
+            return null;
+        }
+
+        try (final ObjectInputStream stream = new ObjectInputStream(new FileInputStream(settingsFile)))
+        {
+            if (stream.readInt() != 5)
+            {
+                return null;
+            }
+            skipBools(stream, 5);
+            skipInts(stream, 9);
+            skipBools(stream, 2);
+            skipInts(stream, 2);
+            stream.readBoolean();
+            skipInts(stream, 2);
+            skipInts(stream, stream.readInt());
+            stream.readInt();
+            skipInts(stream, 6);
+            stream.readInt();
+            skipBools(stream, 8);
+            stream.readInt();
+            stream.readInt();
+            for (int i = 0; i < 4; i++)
+            {
+                stream.readInt();
+                stream.readBoolean();
+            }
+            skipInts(stream, 5);
+            final StringBuilder b = new StringBuilder();
+            final int len = stream.readInt();
+            for (int i = 0; i < len; i++)
+            {
+                b.append(stream.readChar());
+            }
+
+            final Config config = new Config();
+            config.mDataPath = b.toString();
+            Log.v("Retrieved legacy data folder name: " + config.mDataPath);
+            if (!TextUtils.isEmpty(config.mDataPath) && new File(config.mDataPath).exists())
+            {
+                // return config only if there actually is a chance of retrieving old data
+                return config;
+            }
+            Log.i("Couldn't find valid data in legacy config");
+        }
+        catch (final Exception e)
+        {
+            Log.i("Couldn't load legacy config");
+        }
+
+        return null;
+    }
+
+    public static class Config
+    {
+        public String mDataPath;
+    }
+}

+ 67 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/LibsLoader.java

@@ -0,0 +1,67 @@
+package eu.vcmi.vcmi.util;
+
+import android.content.Context;
+import android.os.Build;
+
+import org.libsdl.app.SDL;
+
+import eu.vcmi.vcmi.NativeMethods;
+
+/**
+ * @author F
+ */
+public final class LibsLoader
+{
+    private static void loadLib(final String libName, final boolean onlyForOldApis)
+    {
+        if (!onlyForOldApis || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP)
+        {
+            Log.v("Loading native lib: " + libName);
+            SDL.loadLibrary(libName);
+        }
+    }
+
+    private static void loadCommon()
+    {
+        loadLib("c++_shared", true);
+        loadLib("iconv", true);
+        loadLib("boost-system", true);
+        loadLib("boost-datetime", true);
+        loadLib("boost-locale", true);
+        loadLib("boost-filesystem", true);
+        loadLib("boost-program-options", true);
+        loadLib("boost-thread", true);
+        loadLib("SDL2", false);
+        loadLib("x264", true);
+        loadLib("avutil", true);
+        loadLib("swscale", true);
+        loadLib("swresample", true);
+        loadLib("postproc", true);
+        loadLib("avcodec", true);
+        loadLib("avformat", true);
+        loadLib("avfilter", true);
+        loadLib("avdevice", true);
+        loadLib("minizip", true);
+        loadLib("vcmi-fuzzylite", true);
+        loadLib("vcmi-lib", true);
+        loadLib("SDL2_image", false);
+        loadLib("SDL2_mixer", false);
+        loadLib("SDL2_ttf", false);
+    }
+
+    public static void loadClientLibs(Context ctx)
+    {
+        loadCommon();
+        loadLib("vcmi-client", false);
+        SDL.setContext(ctx);
+        NativeMethods.clientSetupJNI();
+        NativeMethods.initClassloader();
+    }
+
+    public static void loadServerLibs()
+    {
+        loadCommon();
+        loadLib("vcmi-server", false);
+        NativeMethods.initClassloader();
+    }
+}

+ 156 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Log.java

@@ -0,0 +1,156 @@
+package eu.vcmi.vcmi.util;
+
+import android.os.Environment;
+
+import java.io.BufferedWriter;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.text.DateFormat;
+import java.util.Date;
+
+import eu.vcmi.vcmi.BuildConfig;
+import eu.vcmi.vcmi.Const;
+
+/**
+ * @author F
+ */
+
+public class Log
+{
+    private static final boolean LOGGING_ENABLED_CONSOLE = BuildConfig.DEBUG;
+    private static final boolean LOGGING_ENABLED_FILE = true;
+    private static final String FILELOG_PATH = "/" + Const.VCMI_DATA_ROOT_FOLDER_NAME + "/cache/VCMI_launcher.log";
+    private static final String TAG_PREFIX = "VCMI/";
+    private static final String STATIC_TAG = "static";
+
+    private static void log(final int priority, final Object obj, final String msg)
+    {
+        logInternal(priority, tag(obj), msg);
+    }
+
+    private static void logInternal(final int priority, final String tagString, final String msg)
+    {
+        if (LOGGING_ENABLED_CONSOLE)
+        {
+            android.util.Log.println(priority, TAG_PREFIX + tagString, msg);
+        }
+        if (LOGGING_ENABLED_FILE) // this is probably very inefficient, but should be enough for now...
+        {
+            try
+            {
+                final BufferedWriter fileWriter = new BufferedWriter(new FileWriter(Environment.getExternalStorageDirectory() + FILELOG_PATH, true));
+                fileWriter.write(String.format("[%s] %s: %s\n", formatPriority(priority), tagString, msg));
+                fileWriter.flush();
+                fileWriter.close();
+            }
+            catch (IOException ignored)
+            {
+            }
+        }
+    }
+
+    private static String formatPriority(final int priority)
+    {
+        switch (priority)
+        {
+            default:
+                return "?";
+            case android.util.Log.VERBOSE:
+                return "V";
+            case android.util.Log.DEBUG:
+                return "D";
+            case android.util.Log.INFO:
+                return "I";
+            case android.util.Log.WARN:
+                return "W";
+            case android.util.Log.ERROR:
+                return "E";
+        }
+    }
+
+    private static String tag(final Object obj)
+    {
+        if (obj == null)
+        {
+            return "null";
+        }
+        return obj.getClass().getSimpleName();
+    }
+
+    public static void init()
+    {
+        if (LOGGING_ENABLED_FILE) // clear previous log
+        {
+            try
+            {
+                final BufferedWriter fileWriter = new BufferedWriter(new FileWriter(Environment.getExternalStorageDirectory() + FILELOG_PATH, false));
+                fileWriter.write("Starting VCMI launcher log, " + DateFormat.getDateTimeInstance().format(new Date()) + "\n");
+                fileWriter.flush();
+                fileWriter.close();
+            }
+            catch (IOException ignored)
+            {
+            }
+        }
+    }
+
+    public static void v(final String msg)
+    {
+        logInternal(android.util.Log.VERBOSE, STATIC_TAG, msg);
+    }
+
+    public static void d(final String msg)
+    {
+        logInternal(android.util.Log.DEBUG, STATIC_TAG, msg);
+    }
+
+    public static void i(final String msg)
+    {
+        logInternal(android.util.Log.INFO, STATIC_TAG, msg);
+    }
+
+    public static void w(final String msg)
+    {
+        logInternal(android.util.Log.WARN, STATIC_TAG, msg);
+    }
+
+    public static void e(final String msg)
+    {
+        logInternal(android.util.Log.ERROR, STATIC_TAG, msg);
+    }
+
+    public static void v(final Object obj, final String msg)
+    {
+        log(android.util.Log.VERBOSE, obj, msg);
+    }
+
+    public static void d(final Object obj, final String msg)
+    {
+        log(android.util.Log.DEBUG, obj, msg);
+    }
+
+    public static void i(final Object obj, final String msg)
+    {
+        log(android.util.Log.INFO, obj, msg);
+    }
+
+    public static void w(final Object obj, final String msg)
+    {
+        log(android.util.Log.WARN, obj, msg);
+    }
+
+    public static void e(final Object obj, final String msg)
+    {
+        log(android.util.Log.ERROR, obj, msg);
+    }
+
+    public static void e(final Object obj, final String msg, final Throwable e)
+    {
+        log(android.util.Log.ERROR, obj, msg + "\n" + android.util.Log.getStackTraceString(e));
+    }
+
+    public static void e(final String msg, final Throwable e)
+    {
+        logInternal(android.util.Log.ERROR, STATIC_TAG, msg + "\n" + android.util.Log.getStackTraceString(e));
+    }
+}

+ 30 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/ServerResponse.java

@@ -0,0 +1,30 @@
+package eu.vcmi.vcmi.util;
+
+/**
+ * @author F
+ */
+public class ServerResponse<T>
+{
+    public static final int LOCAL_ERROR_IO = -1;
+    public static final int LOCAL_ERROR_PARSING = -2;
+
+    public int mCode;
+    public String mRawContent;
+    public T mContent;
+
+    public ServerResponse(final int code, final String content)
+    {
+        mCode = code;
+        mRawContent = content;
+    }
+
+    public static boolean isResponseCodeValid(final int responseCode)
+    {
+        return responseCode >= 200 && responseCode < 300;
+    }
+
+    public boolean isValid()
+    {
+        return isResponseCodeValid(mCode);
+    }
+}

+ 92 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/SharedPrefs.java

@@ -0,0 +1,92 @@
+package eu.vcmi.vcmi.util;
+
+import android.content.Context;
+import android.content.SharedPreferences;
+import androidx.annotation.NonNull;
+
+/**
+ * simple shared preferences wrapper
+ *
+ * @author F
+ */
+public class SharedPrefs
+{
+    public static final String KEY_CURRENT_INTERNAL_ASSET_HASH = "KEY_CURRENT_INTERNAL_ASSET_HASH"; // [string]
+    private static final String VCMI_PREFS_NAME = "VCMIPrefs";
+    private final SharedPreferences mPrefs;
+
+    public SharedPrefs(final Context ctx)
+    {
+        mPrefs = ctx.getSharedPreferences(VCMI_PREFS_NAME, Context.MODE_PRIVATE);
+    }
+
+    public void save(final String name, final String value)
+    {
+        mPrefs.edit().putString(name, value).apply();
+        log(name, value, true);
+    }
+
+    public String load(final String name, final String defaultValue)
+    {
+        return log(name, mPrefs.getString(name, defaultValue), false);
+    }
+
+    public void save(final String name, final int value)
+    {
+        mPrefs.edit().putInt(name, value).apply();
+        log(name, value, true);
+    }
+
+    public int load(final String name, final int defaultValue)
+    {
+        return log(name, mPrefs.getInt(name, defaultValue), false);
+    }
+
+    public void save(final String name, final float value)
+    {
+        mPrefs.edit().putFloat(name, value).apply();
+        log(name, value, true);
+    }
+
+    public float load(final String name, final float defaultValue)
+    {
+        return log(name, mPrefs.getFloat(name, defaultValue), false);
+    }
+
+    public void save(final String name, final boolean value)
+    {
+        mPrefs.edit().putBoolean(name, value).apply();
+        log(name, value, true);
+    }
+
+    public boolean load(final String name, final boolean defaultValue)
+    {
+        return log(name, mPrefs.getBoolean(name, defaultValue), false);
+    }
+
+    public <T extends Enum<T>> void saveEnum(final String name, final T value)
+    {
+        mPrefs.edit().putInt(name, value.ordinal()).apply();
+        log(name, value, true);
+    }
+
+    @SuppressWarnings("unchecked")
+    public <T extends Enum<T>> T loadEnum(final String name, @NonNull final T defaultValue)
+    {
+        final int rawValue = mPrefs.getInt(name, defaultValue.ordinal());
+        return (T) log(name, defaultValue.getClass().getEnumConstants()[rawValue], false);
+    }
+
+    private <T> T log(final String key, final T value, final boolean saving)
+    {
+        if (saving)
+        {
+            Log.v(this, "[prefs saving] " + key + " => " + value);
+        }
+        else
+        {
+            Log.v(this, "[prefs loading] " + key + " => " + value);
+        }
+        return value;
+    }
+}

+ 58 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/util/Utils.java

@@ -0,0 +1,58 @@
+package eu.vcmi.vcmi.util;
+
+import android.content.Context;
+import android.content.pm.PackageInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Resources;
+import android.util.DisplayMetrics;
+
+/**
+ * @author F
+ */
+
+public final class Utils
+{
+    private static String sAppVersionCache;
+
+    private Utils()
+    {
+    }
+
+    public static String appVersionName(final Context ctx)
+    {
+        if (sAppVersionCache == null)
+        {
+            final PackageManager pm = ctx.getPackageManager();
+            try
+            {
+                final PackageInfo info = pm.getPackageInfo(ctx.getPackageName(), PackageManager.GET_META_DATA);
+                return sAppVersionCache = info.versionName;
+            }
+            catch (final PackageManager.NameNotFoundException e)
+            {
+                Log.e(ctx, "Couldn't resolve app version", e);
+            }
+        }
+        return sAppVersionCache;
+    }
+
+    public static float convertDpToPx(final Context ctx, final float dp)
+    {
+        return convertDpToPx(ctx.getResources(), dp);
+    }
+
+    public static float convertDpToPx(final Resources res, final float dp)
+    {
+        return dp * res.getDisplayMetrics().density;
+    }
+
+    public static float convertPxToDp(final Context ctx, final float px)
+    {
+        return convertPxToDp(ctx.getResources(), px);
+    }
+
+    public static float convertPxToDp(final Resources res, final float px)
+    {
+        return px / res.getDisplayMetrics().density;
+    }
+}

+ 52 - 0
android/vcmi-app/src/main/java/eu/vcmi/vcmi/viewmodels/ObservableViewModel.java

@@ -0,0 +1,52 @@
+package eu.vcmi.vcmi.viewmodels;
+
+import android.view.View;
+
+import androidx.lifecycle.ViewModel;
+import androidx.databinding.PropertyChangeRegistry;
+import androidx.databinding.Observable;
+
+/**
+ * @author F
+ */
+public class ObservableViewModel extends ViewModel implements Observable
+{
+    private PropertyChangeRegistry callbacks = new PropertyChangeRegistry();
+
+    @Override
+    public void addOnPropertyChangedCallback(
+            Observable.OnPropertyChangedCallback callback)
+    {
+        callbacks.add(callback);
+    }
+
+    @Override
+    public void removeOnPropertyChangedCallback(
+            Observable.OnPropertyChangedCallback callback)
+    {
+        callbacks.remove(callback);
+    }
+
+    public int visible(boolean isVisible)
+    {
+        return isVisible ? View.VISIBLE : View.GONE;
+    }
+
+    /**
+     * Notifies observers that all properties of this instance have changed.
+     */
+    void notifyChange() {
+        callbacks.notifyCallbacks(this, 0, null);
+    }
+
+    /**
+     * Notifies observers that a specific property has changed. The getter for the
+     * property that changes should be marked with the @Bindable annotation to
+     * generate a field in the BR class to be used as the fieldId parameter.
+     *
+     * @param fieldId The generated BR id for the Bindable field.
+     */
+    void notifyPropertyChanged(int fieldId) {
+        callbacks.notifyCallbacks(this, fieldId, null);
+    }
+}

+ 74 - 0
android/vcmi-app/src/main/java/org/libsdl/app/DummyEdit.java

@@ -0,0 +1,74 @@
+package org.libsdl.app;
+
+import android.content.Context;
+import android.text.InputType;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+
+/* This is a fake invisible editor view that receives the input and defines the
+ * pan&scan region
+ */
+public class DummyEdit extends View implements View.OnKeyListener
+{
+    InputConnection ic;
+
+    public DummyEdit(Context context)
+    {
+        super(context);
+        setFocusableInTouchMode(true);
+        setFocusable(true);
+        setOnKeyListener(this);
+    }
+
+    @Override
+    public boolean onCheckIsTextEditor()
+    {
+        return true;
+    }
+
+    @Override
+    public boolean onKey(View v, int keyCode, KeyEvent event)
+    {
+        return SDLActivity.handleKeyEvent(v, keyCode, event, ic);
+    }
+
+    //
+    @Override
+    public boolean onKeyPreIme(int keyCode, KeyEvent event)
+    {
+        // As seen on StackOverflow: http://stackoverflow.com/questions/7634346/keyboard-hide-event
+        // FIXME: Discussion at http://bugzilla.libsdl.org/show_bug.cgi?id=1639
+        // FIXME: This is not a 100% effective solution to the problem of detecting if the keyboard is showing or not
+        // FIXME: A more effective solution would be to assume our Layout to be RelativeLayout or LinearLayout
+        // FIXME: And determine the keyboard presence doing this: http://stackoverflow.com/questions/2150078/how-to-check-visibility-of-software-keyboard-in-android
+        // FIXME: An even more effective way would be if Android provided this out of the box, but where would the fun be in that :)
+        if (event.getAction() == KeyEvent.ACTION_UP && keyCode == KeyEvent.KEYCODE_BACK)
+        {
+            if (SDLActivity.mTextEdit != null && SDLActivity.mTextEdit.getVisibility() == View.VISIBLE)
+            {
+                SDLActivity.onNativeKeyboardFocusLost();
+            }
+        }
+        return super.onKeyPreIme(keyCode, event);
+    }
+
+    @Override
+    public InputConnection onCreateInputConnection(EditorInfo outAttrs)
+    {
+        ic = new SDLInputConnection(this, true);
+
+        outAttrs.inputType = InputType.TYPE_CLASS_TEXT |
+                InputType.TYPE_TEXT_FLAG_MULTI_LINE;
+        outAttrs.imeOptions = EditorInfo.IME_FLAG_NO_EXTRACT_UI |
+                EditorInfo.IME_FLAG_NO_FULLSCREEN /* API 11 */;
+
+        return ic;
+    }
+
+    public InputConnection getInputConnection()
+    {
+        return ic;
+    }
+}

+ 22 - 0
android/vcmi-app/src/main/java/org/libsdl/app/HIDDevice.java

@@ -0,0 +1,22 @@
+package org.libsdl.app;
+
+import android.hardware.usb.UsbDevice;
+
+interface HIDDevice
+{
+    public int getId();
+    public int getVendorId();
+    public int getProductId();
+    public String getSerialNumber();
+    public int getVersion();
+    public String getManufacturerName();
+    public String getProductName();
+    public UsbDevice getDevice();
+    public boolean open();
+    public int sendFeatureReport(byte[] report);
+    public int sendOutputReport(byte[] report);
+    public boolean getFeatureReport(byte[] report);
+    public void setFrozen(boolean frozen);
+    public void close();
+    public void shutdown();
+}

+ 650 - 0
android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java

@@ -0,0 +1,650 @@
+package org.libsdl.app;
+
+import android.content.Context;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothGatt;
+import android.bluetooth.BluetoothGattCallback;
+import android.bluetooth.BluetoothGattCharacteristic;
+import android.bluetooth.BluetoothGattDescriptor;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.bluetooth.BluetoothGattService;
+import android.hardware.usb.UsbDevice;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.os.*;
+
+//import com.android.internal.util.HexDump;
+
+import java.lang.Runnable;
+import java.util.Arrays;
+import java.util.LinkedList;
+import java.util.UUID;
+
+class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice {
+
+    private static final String TAG = "hidapi";
+    private HIDDeviceManager mManager;
+    private BluetoothDevice mDevice;
+    private int mDeviceId;
+    private BluetoothGatt mGatt;
+    private boolean mIsRegistered = false;
+    private boolean mIsConnected = false;
+    private boolean mIsChromebook = false;
+    private boolean mIsReconnecting = false;
+    private boolean mFrozen = false;
+    private LinkedList<GattOperation> mOperations;
+    GattOperation mCurrentOperation = null;
+    private Handler mHandler;
+
+    private static final int TRANSPORT_AUTO = 0;
+    private static final int TRANSPORT_BREDR = 1;
+    private static final int TRANSPORT_LE = 2;
+
+    private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000;
+
+    static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3");
+    static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3");
+    static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3");
+    static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 };
+
+    static class GattOperation {
+        private enum Operation {
+            CHR_READ,
+            CHR_WRITE,
+            ENABLE_NOTIFICATION
+        }
+
+        Operation mOp;
+        UUID mUuid;
+        byte[] mValue;
+        BluetoothGatt mGatt;
+        boolean mResult = true;
+
+        private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) {
+            mGatt = gatt;
+            mOp = operation;
+            mUuid = uuid;
+        }
+
+        private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) {
+            mGatt = gatt;
+            mOp = operation;
+            mUuid = uuid;
+            mValue = value;
+        }
+
+        public void run() {
+            // This is executed in main thread
+            BluetoothGattCharacteristic chr;
+
+            switch (mOp) {
+                case CHR_READ:
+                    chr = getCharacteristic(mUuid);
+                    //Log.v(TAG, "Reading characteristic " + chr.getUuid());
+                    if (!mGatt.readCharacteristic(chr)) {
+                        Log.e(TAG, "Unable to read characteristic " + mUuid.toString());
+                        mResult = false;
+                        break;
+                    }
+                    mResult = true;
+                    break;
+                case CHR_WRITE:
+                    chr = getCharacteristic(mUuid);
+                    //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value));
+                    chr.setValue(mValue);
+                    if (!mGatt.writeCharacteristic(chr)) {
+                        Log.e(TAG, "Unable to write characteristic " + mUuid.toString());
+                        mResult = false;
+                        break;
+                    }
+                    mResult = true;
+                    break;
+                case ENABLE_NOTIFICATION:
+                    chr = getCharacteristic(mUuid);
+                    //Log.v(TAG, "Writing descriptor of " + chr.getUuid());
+                    if (chr != null) {
+                        BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+                        if (cccd != null) {
+                            int properties = chr.getProperties();
+                            byte[] value;
+                            if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) {
+                                value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE;
+                            } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) {
+                                value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE;
+                            } else {
+                                Log.e(TAG, "Unable to start notifications on input characteristic");
+                                mResult = false;
+                                return;
+                            }
+
+                            mGatt.setCharacteristicNotification(chr, true);
+                            cccd.setValue(value);
+                            if (!mGatt.writeDescriptor(cccd)) {
+                                Log.e(TAG, "Unable to write descriptor " + mUuid.toString());
+                                mResult = false;
+                                return;
+                            }
+                            mResult = true;
+                        }
+                    }
+            }
+        }
+
+        public boolean finish() {
+            return mResult;
+        }
+
+        private BluetoothGattCharacteristic getCharacteristic(UUID uuid) {
+            BluetoothGattService valveService = mGatt.getService(steamControllerService);
+            if (valveService == null)
+                return null;
+            return valveService.getCharacteristic(uuid);
+        }
+
+        static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) {
+            return new GattOperation(gatt, Operation.CHR_READ, uuid);
+        }
+
+        static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) {
+            return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value);
+        }
+
+        static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) {
+            return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid);
+        }
+    }
+
+    public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) {
+        mManager = manager;
+        mDevice = device;
+        mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier());
+        mIsRegistered = false;
+        mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+        mOperations = new LinkedList<GattOperation>();
+        mHandler = new Handler(Looper.getMainLooper());
+
+        mGatt = connectGatt();
+        // final HIDDeviceBLESteamController finalThis = this;
+        // mHandler.postDelayed(new Runnable() {
+        //     @Override
+        //     public void run() {
+        //         finalThis.checkConnectionForChromebookIssue();
+        //     }
+        // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+    }
+
+    public String getIdentifier() {
+        return String.format("SteamController.%s", mDevice.getAddress());
+    }
+
+    public BluetoothGatt getGatt() {
+        return mGatt;
+    }
+
+    // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead
+    // of TRANSPORT_LE.  Let's force ourselves to connect low energy.
+    private BluetoothGatt connectGatt(boolean managed) {
+        if (Build.VERSION.SDK_INT >= 23) {
+            try {
+                return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE);
+            } catch (Exception e) {
+                return mDevice.connectGatt(mManager.getContext(), managed, this);
+            }
+        } else {
+            return mDevice.connectGatt(mManager.getContext(), managed, this);
+        }
+    }
+
+    private BluetoothGatt connectGatt() {
+        return connectGatt(false);
+    }
+
+    protected int getConnectionState() {
+
+        Context context = mManager.getContext();
+        if (context == null) {
+            // We are lacking any context to get our Bluetooth information.  We'll just assume disconnected.
+            return BluetoothProfile.STATE_DISCONNECTED;
+        }
+
+        BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE);
+        if (btManager == null) {
+            // This device doesn't support Bluetooth.  We should never be here, because how did
+            // we instantiate a device to start with?
+            return BluetoothProfile.STATE_DISCONNECTED;
+        }
+
+        return btManager.getConnectionState(mDevice, BluetoothProfile.GATT);
+    }
+
+    public void reconnect() {
+
+        if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) {
+            mGatt.disconnect();
+            mGatt = connectGatt();
+        }
+
+    }
+
+    protected void checkConnectionForChromebookIssue() {
+        if (!mIsChromebook) {
+            // We only do this on Chromebooks, because otherwise it's really annoying to just attempt
+            // over and over.
+            return;
+        }
+
+        int connectionState = getConnectionState();
+
+        switch (connectionState) {
+            case BluetoothProfile.STATE_CONNECTED:
+                if (!mIsConnected) {
+                    // We are in the Bad Chromebook Place.  We can force a disconnect
+                    // to try to recover.
+                    Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback.  Forcing a reconnect.");
+                    mIsReconnecting = true;
+                    mGatt.disconnect();
+                    mGatt = connectGatt(false);
+                    break;
+                }
+                else if (!isRegistered()) {
+                    if (mGatt.getServices().size() > 0) {
+                        Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration.  Trying to recover.");
+                        probeService(this);
+                    }
+                    else {
+                        Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services.  Trying to recover.");
+                        mIsReconnecting = true;
+                        mGatt.disconnect();
+                        mGatt = connectGatt(false);
+                        break;
+                    }
+                }
+                else {
+                    Log.v(TAG, "Chromebook: We are connected, and registered.  Everything's good!");
+                    return;
+                }
+                break;
+
+            case BluetoothProfile.STATE_DISCONNECTED:
+                Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us.  Attempting a disconnect/reconnect, but we may not be able to recover.");
+
+                mIsReconnecting = true;
+                mGatt.disconnect();
+                mGatt = connectGatt(false);
+                break;
+
+            case BluetoothProfile.STATE_CONNECTING:
+                Log.v(TAG, "Chromebook: We're still trying to connect.  Waiting a bit longer.");
+                break;
+        }
+
+        final HIDDeviceBLESteamController finalThis = this;
+        mHandler.postDelayed(new Runnable() {
+            @Override
+            public void run() {
+                finalThis.checkConnectionForChromebookIssue();
+            }
+        }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL);
+    }
+
+    private boolean isRegistered() {
+        return mIsRegistered;
+    }
+
+    private void setRegistered() {
+        mIsRegistered = true;
+    }
+
+    private boolean probeService(HIDDeviceBLESteamController controller) {
+
+        if (isRegistered()) {
+            return true;
+        }
+
+        if (!mIsConnected) {
+            return false;
+        }
+
+        Log.v(TAG, "probeService controller=" + controller);
+
+        for (BluetoothGattService service : mGatt.getServices()) {
+            if (service.getUuid().equals(steamControllerService)) {
+                Log.v(TAG, "Found Valve steam controller service " + service.getUuid());
+
+                for (BluetoothGattCharacteristic chr : service.getCharacteristics()) {
+                    if (chr.getUuid().equals(inputCharacteristic)) {
+                        Log.v(TAG, "Found input characteristic");
+                        // Start notifications
+                        BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb"));
+                        if (cccd != null) {
+                            enableNotification(chr.getUuid());
+                        }
+                    }
+                }
+                return true;
+            }
+        }
+
+        if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) {
+            Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us.");
+            mIsConnected = false;
+            mIsReconnecting = true;
+            mGatt.disconnect();
+            mGatt = connectGatt(false);
+        }
+
+        return false;
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private void finishCurrentGattOperation() {
+        GattOperation op = null;
+        synchronized (mOperations) {
+            if (mCurrentOperation != null) {
+                op = mCurrentOperation;
+                mCurrentOperation = null;
+            }
+        }
+        if (op != null) {
+            boolean result = op.finish(); // TODO: Maybe in main thread as well?
+
+            // Our operation failed, let's add it back to the beginning of our queue.
+            if (!result) {
+                mOperations.addFirst(op);
+            }
+        }
+        executeNextGattOperation();
+    }
+
+    private void executeNextGattOperation() {
+        synchronized (mOperations) {
+            if (mCurrentOperation != null)
+                return;
+
+            if (mOperations.isEmpty())
+                return;
+
+            mCurrentOperation = mOperations.removeFirst();
+        }
+
+        // Run in main thread
+        mHandler.post(new Runnable() {
+            @Override
+            public void run() {
+                synchronized (mOperations) {
+                    if (mCurrentOperation == null) {
+                        Log.e(TAG, "Current operation null in executor?");
+                        return;
+                    }
+
+                    mCurrentOperation.run();
+                    // now wait for the GATT callback and when it comes, finish this operation
+                }
+            }
+        });
+    }
+
+    private void queueGattOperation(GattOperation op) {
+        synchronized (mOperations) {
+            mOperations.add(op);
+        }
+        executeNextGattOperation();
+    }
+
+    private void enableNotification(UUID chrUuid) {
+        GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid);
+        queueGattOperation(op);
+    }
+
+    public void writeCharacteristic(UUID uuid, byte[] value) {
+        GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value);
+        queueGattOperation(op);
+    }
+
+    public void readCharacteristic(UUID uuid) {
+        GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid);
+        queueGattOperation(op);
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////////////  BluetoothGattCallback overridden methods
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public void onConnectionStateChange(BluetoothGatt g, int status, int newState) {
+        //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState);
+        mIsReconnecting = false;
+        if (newState == 2) {
+            mIsConnected = true;
+            // Run directly, without GattOperation
+            if (!isRegistered()) {
+                mHandler.post(new Runnable() {
+                    @Override
+                    public void run() {
+                        mGatt.discoverServices();
+                    }
+                });
+            }
+        } 
+        else if (newState == 0) {
+            mIsConnected = false;
+        }
+
+        // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent.
+    }
+
+    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
+        //Log.v(TAG, "onServicesDiscovered status=" + status);
+        if (status == 0) {
+            if (gatt.getServices().size() == 0) {
+                Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack.");
+                mIsReconnecting = true;
+                mIsConnected = false;
+                gatt.disconnect();
+                mGatt = connectGatt(false);
+            }
+            else {
+                probeService(this);
+            }
+        }
+    }
+
+    public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+        //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid());
+
+        if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) {
+            mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue());
+        }
+
+        finishCurrentGattOperation();
+    }
+
+    public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) {
+        //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid());
+
+        if (characteristic.getUuid().equals(reportCharacteristic)) {
+            // Only register controller with the native side once it has been fully configured
+            if (!isRegistered()) {
+                Log.v(TAG, "Registering Steam Controller with ID: " + getId());
+                mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0);
+                setRegistered();
+            }
+        }
+
+        finishCurrentGattOperation();
+    }
+
+    public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
+    // Enable this for verbose logging of controller input reports
+        //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue()));
+
+        if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) {
+            mManager.HIDDeviceInputReport(getId(), characteristic.getValue());
+        }
+    }
+
+    public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+        //Log.v(TAG, "onDescriptorRead status=" + status);
+    }
+
+    public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) {
+        BluetoothGattCharacteristic chr = descriptor.getCharacteristic();
+        //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid());
+
+        if (chr.getUuid().equals(inputCharacteristic)) {
+            boolean hasWrittenInputDescriptor = true;
+            BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic);
+            if (reportChr != null) {
+                Log.v(TAG, "Writing report characteristic to enter valve mode");
+                reportChr.setValue(enterValveMode);
+                gatt.writeCharacteristic(reportChr);
+            }
+        }
+
+        finishCurrentGattOperation();
+    }
+
+    public void onReliableWriteCompleted(BluetoothGatt gatt, int status) {
+        //Log.v(TAG, "onReliableWriteCompleted status=" + status);
+    }
+
+    public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) {
+        //Log.v(TAG, "onReadRemoteRssi status=" + status);
+    }
+
+    public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) {
+        //Log.v(TAG, "onMtuChanged status=" + status);
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////// Public API
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    @Override
+    public int getId() {
+        return mDeviceId;
+    }
+
+    @Override
+    public int getVendorId() {
+        // Valve Corporation
+        final int VALVE_USB_VID = 0x28DE;
+        return VALVE_USB_VID;
+    }
+
+    @Override
+    public int getProductId() {
+        // We don't have an easy way to query from the Bluetooth device, but we know what it is
+        final int D0G_BLE2_PID = 0x1106;
+        return D0G_BLE2_PID;
+    }
+
+    @Override
+    public String getSerialNumber() {
+        // This will be read later via feature report by Steam
+        return "12345";
+    }
+
+    @Override
+    public int getVersion() {
+        return 0;
+    }
+
+    @Override
+    public String getManufacturerName() {
+        return "Valve Corporation";
+    }
+
+    @Override
+    public String getProductName() {
+        return "Steam Controller";
+    }
+
+    @Override
+    public UsbDevice getDevice() {
+        return null;
+    }
+
+    @Override
+    public boolean open() {
+        return true;
+    }
+
+    @Override
+    public int sendFeatureReport(byte[] report) {
+        if (!isRegistered()) {
+            Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!");
+            if (mIsConnected) {
+                probeService(this);
+            }
+            return -1;
+        }
+
+        // We need to skip the first byte, as that doesn't go over the air
+        byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1);
+        //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report));
+        writeCharacteristic(reportCharacteristic, actual_report);
+        return report.length;
+    }
+
+    @Override
+    public int sendOutputReport(byte[] report) {
+        if (!isRegistered()) {
+            Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!");
+            if (mIsConnected) {
+                probeService(this);
+            }
+            return -1;
+        }
+
+        //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report));
+        writeCharacteristic(reportCharacteristic, report);
+        return report.length;
+    }
+
+    @Override
+    public boolean getFeatureReport(byte[] report) {
+        if (!isRegistered()) {
+            Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!");
+            if (mIsConnected) {
+                probeService(this);
+            }
+            return false;
+        }
+
+        //Log.v(TAG, "getFeatureReport");
+        readCharacteristic(reportCharacteristic);
+        return true;
+    }
+
+    @Override
+    public void close() {
+    }
+
+    @Override
+    public void setFrozen(boolean frozen) {
+        mFrozen = frozen;
+    }
+
+    @Override
+    public void shutdown() {
+        close();
+
+        BluetoothGatt g = mGatt;
+        if (g != null) {
+            g.disconnect();
+            g.close();
+            mGatt = null;
+        }
+        mManager = null;
+        mIsRegistered = false;
+        mIsConnected = false;
+        mOperations.clear();
+    }
+
+}
+

+ 679 - 0
android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceManager.java

@@ -0,0 +1,679 @@
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.PendingIntent;
+import android.bluetooth.BluetoothAdapter;
+import android.bluetooth.BluetoothDevice;
+import android.bluetooth.BluetoothManager;
+import android.bluetooth.BluetoothProfile;
+import android.os.Build;
+import android.util.Log;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.hardware.usb.*;
+import android.os.Handler;
+import android.os.Looper;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+
+public class HIDDeviceManager {
+    private static final String TAG = "hidapi";
+    private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION";
+
+    private static HIDDeviceManager sManager;
+    private static int sManagerRefCount = 0;
+
+    public static HIDDeviceManager acquire(Context context) {
+        if (sManagerRefCount == 0) {
+            sManager = new HIDDeviceManager(context);
+        }
+        ++sManagerRefCount;
+        return sManager;
+    }
+
+    public static void release(HIDDeviceManager manager) {
+        if (manager == sManager) {
+            --sManagerRefCount;
+            if (sManagerRefCount == 0) {
+                sManager.close();
+                sManager = null;
+            }
+        }
+    }
+
+    private Context mContext;
+    private HashMap<Integer, HIDDevice> mDevicesById = new HashMap<Integer, HIDDevice>();
+    private HashMap<BluetoothDevice, HIDDeviceBLESteamController> mBluetoothDevices = new HashMap<BluetoothDevice, HIDDeviceBLESteamController>();
+    private int mNextDeviceId = 0;
+    private SharedPreferences mSharedPreferences = null;
+    private boolean mIsChromebook = false;
+    private UsbManager mUsbManager;
+    private Handler mHandler;
+    private BluetoothManager mBluetoothManager;
+    private List<BluetoothDevice> mLastBluetoothDevices;
+
+    private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            String action = intent.getAction();
+            if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) {
+                UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+                handleUsbDeviceAttached(usbDevice);
+            } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) {
+                UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+                handleUsbDeviceDetached(usbDevice);
+            } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) {
+                UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
+                handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false));
+            }
+        }
+    };
+
+    private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() {
+        @Override
+        public void onReceive(Context context, Intent intent) {
+            String action = intent.getAction();
+            // Bluetooth device was connected. If it was a Steam Controller, handle it
+            if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) {
+                BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+                Log.d(TAG, "Bluetooth device connected: " + device);
+
+                if (isSteamController(device)) {
+                    connectBluetoothDevice(device);
+                }
+            }
+
+            // Bluetooth device was disconnected, remove from controller manager (if any)
+            if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) {
+                BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
+                Log.d(TAG, "Bluetooth device disconnected: " + device);
+
+                disconnectBluetoothDevice(device);
+            }
+        }
+    };
+
+    private HIDDeviceManager(final Context context) {
+        mContext = context;
+
+        HIDDeviceRegisterCallback();
+
+        mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE);
+        mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+
+//        if (shouldClear) {
+//            SharedPreferences.Editor spedit = mSharedPreferences.edit();
+//            spedit.clear();
+//            spedit.commit();
+//        }
+//        else
+        {
+            mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0);
+        }
+    }
+
+    public Context getContext() {
+        return mContext;
+    }
+
+    public int getDeviceIDForIdentifier(String identifier) {
+        SharedPreferences.Editor spedit = mSharedPreferences.edit();
+
+        int result = mSharedPreferences.getInt(identifier, 0);
+        if (result == 0) {
+            result = mNextDeviceId++;
+            spedit.putInt("next_device_id", mNextDeviceId);
+        }
+
+        spedit.putInt(identifier, result);
+        spedit.commit();
+        return result;
+    }
+
+    private void initializeUSB() {
+        mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE);
+        if (mUsbManager == null) {
+            return;
+        }
+
+        /*
+        // Logging
+        for (UsbDevice device : mUsbManager.getDeviceList().values()) {
+            Log.i(TAG,"Path: " + device.getDeviceName());
+            Log.i(TAG,"Manufacturer: " + device.getManufacturerName());
+            Log.i(TAG,"Product: " + device.getProductName());
+            Log.i(TAG,"ID: " + device.getDeviceId());
+            Log.i(TAG,"Class: " + device.getDeviceClass());
+            Log.i(TAG,"Protocol: " + device.getDeviceProtocol());
+            Log.i(TAG,"Vendor ID " + device.getVendorId());
+            Log.i(TAG,"Product ID: " + device.getProductId());
+            Log.i(TAG,"Interface count: " + device.getInterfaceCount());
+            Log.i(TAG,"---------------------------------------");
+
+            // Get interface details
+            for (int index = 0; index < device.getInterfaceCount(); index++) {
+                UsbInterface mUsbInterface = device.getInterface(index);
+                Log.i(TAG,"  *****     *****");
+                Log.i(TAG,"  Interface index: " + index);
+                Log.i(TAG,"  Interface ID: " + mUsbInterface.getId());
+                Log.i(TAG,"  Interface class: " + mUsbInterface.getInterfaceClass());
+                Log.i(TAG,"  Interface subclass: " + mUsbInterface.getInterfaceSubclass());
+                Log.i(TAG,"  Interface protocol: " + mUsbInterface.getInterfaceProtocol());
+                Log.i(TAG,"  Endpoint count: " + mUsbInterface.getEndpointCount());
+
+                // Get endpoint details 
+                for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++)
+                {
+                    UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi);
+                    Log.i(TAG,"    ++++   ++++   ++++");
+                    Log.i(TAG,"    Endpoint index: " + epi);
+                    Log.i(TAG,"    Attributes: " + mEndpoint.getAttributes());
+                    Log.i(TAG,"    Direction: " + mEndpoint.getDirection());
+                    Log.i(TAG,"    Number: " + mEndpoint.getEndpointNumber());
+                    Log.i(TAG,"    Interval: " + mEndpoint.getInterval());
+                    Log.i(TAG,"    Packet size: " + mEndpoint.getMaxPacketSize());
+                    Log.i(TAG,"    Type: " + mEndpoint.getType());
+                }
+            }
+        }
+        Log.i(TAG," No more devices connected.");
+        */
+
+        // Register for USB broadcasts and permission completions
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED);
+        filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
+        filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION);
+        mContext.registerReceiver(mUsbBroadcast, filter);
+
+        for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) {
+            handleUsbDeviceAttached(usbDevice);
+        }
+    }
+
+    UsbManager getUSBManager() {
+        return mUsbManager;
+    }
+
+    private void shutdownUSB() {
+        try {
+            mContext.unregisterReceiver(mUsbBroadcast);
+        } catch (Exception e) {
+            // We may not have registered, that's okay
+        }
+    }
+
+    private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) {
+        if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) {
+            return true;
+        }
+        if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) {
+            return true;
+        }
+        return false;
+    }
+
+    private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) {
+        final int XB360_IFACE_SUBCLASS = 93;
+        final int XB360_IFACE_PROTOCOL = 1; // Wired
+        final int XB360W_IFACE_PROTOCOL = 129; // Wireless
+        final int[] SUPPORTED_VENDORS = {
+            0x0079, // GPD Win 2
+            0x044f, // Thrustmaster
+            0x045e, // Microsoft
+            0x046d, // Logitech
+            0x056e, // Elecom
+            0x06a3, // Saitek
+            0x0738, // Mad Catz
+            0x07ff, // Mad Catz
+            0x0e6f, // PDP
+            0x0f0d, // Hori
+            0x1038, // SteelSeries
+            0x11c9, // Nacon
+            0x12ab, // Unknown
+            0x1430, // RedOctane
+            0x146b, // BigBen
+            0x1532, // Razer Sabertooth
+            0x15e4, // Numark
+            0x162e, // Joytech
+            0x1689, // Razer Onza
+            0x1949, // Lab126, Inc.
+            0x1bad, // Harmonix
+            0x20d6, // PowerA
+            0x24c6, // PowerA
+            0x2c22, // Qanba
+        };
+
+        if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+            usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS &&
+            (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL ||
+             usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) {
+            int vendor_id = usbDevice.getVendorId();
+            for (int supportedVid : SUPPORTED_VENDORS) {
+                if (vendor_id == supportedVid) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) {
+        final int XB1_IFACE_SUBCLASS = 71;
+        final int XB1_IFACE_PROTOCOL = 208;
+        final int[] SUPPORTED_VENDORS = {
+            0x045e, // Microsoft
+            0x0738, // Mad Catz
+            0x0e6f, // PDP
+            0x0f0d, // Hori
+            0x1532, // Razer Wildcat
+            0x20d6, // PowerA
+            0x24c6, // PowerA
+            0x2dc8, /* 8BitDo */
+            0x2e24, // Hyperkin
+        };
+
+        if (usbInterface.getId() == 0 &&
+            usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC &&
+            usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS &&
+            usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) {
+            int vendor_id = usbDevice.getVendorId();
+            for (int supportedVid : SUPPORTED_VENDORS) {
+                if (vendor_id == supportedVid) {
+                    return true;
+                }
+            }
+        }
+        return false;
+    }
+
+    private void handleUsbDeviceAttached(UsbDevice usbDevice) {
+        connectHIDDeviceUSB(usbDevice);
+    }
+
+    private void handleUsbDeviceDetached(UsbDevice usbDevice) {
+        List<Integer> devices = new ArrayList<Integer>();
+        for (HIDDevice device : mDevicesById.values()) {
+            if (usbDevice.equals(device.getDevice())) {
+                devices.add(device.getId());
+            }
+        }
+        for (int id : devices) {
+            HIDDevice device = mDevicesById.get(id);
+            mDevicesById.remove(id);
+            device.shutdown();
+            HIDDeviceDisconnected(id);
+        }
+    }
+
+    private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) {
+        for (HIDDevice device : mDevicesById.values()) {
+            if (usbDevice.equals(device.getDevice())) {
+                boolean opened = false;
+                if (permission_granted) {
+                    opened = device.open();
+                }
+                HIDDeviceOpenResult(device.getId(), opened);
+            }
+        }
+    }
+
+    private void connectHIDDeviceUSB(UsbDevice usbDevice) {
+        synchronized (this) {
+            int interface_mask = 0;
+            for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) {
+                UsbInterface usbInterface = usbDevice.getInterface(interface_index);
+                if (isHIDDeviceInterface(usbDevice, usbInterface)) {
+                    // Check to see if we've already added this interface
+                    // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive
+                    int interface_id = usbInterface.getId();
+                    if ((interface_mask & (1 << interface_id)) != 0) {
+                        continue;
+                    }
+                    interface_mask |= (1 << interface_id);
+
+                    HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index);
+                    int id = device.getId();
+                    mDevicesById.put(id, device);
+                    HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol());
+                }
+            }
+        }
+    }
+
+    private void initializeBluetooth() {
+        Log.d(TAG, "Initializing Bluetooth");
+
+        if (Build.VERSION.SDK_INT <= 30 &&
+            mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) {
+            Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH");
+            return;
+        }
+
+        if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18)) {
+            Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE");
+            return;
+        }
+
+        // Find bonded bluetooth controllers and create SteamControllers for them
+        mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE);
+        if (mBluetoothManager == null) {
+            // This device doesn't support Bluetooth.
+            return;
+        }
+
+        BluetoothAdapter btAdapter = mBluetoothManager.getAdapter();
+        if (btAdapter == null) {
+            // This device has Bluetooth support in the codebase, but has no available adapters.
+            return;
+        }
+
+        // Get our bonded devices.
+        for (BluetoothDevice device : btAdapter.getBondedDevices()) {
+
+            Log.d(TAG, "Bluetooth device available: " + device);
+            if (isSteamController(device)) {
+                connectBluetoothDevice(device);
+            }
+
+        }
+
+        // NOTE: These don't work on Chromebooks, to my undying dismay.
+        IntentFilter filter = new IntentFilter();
+        filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED);
+        filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED);
+        mContext.registerReceiver(mBluetoothBroadcast, filter);
+
+        if (mIsChromebook) {
+            mHandler = new Handler(Looper.getMainLooper());
+            mLastBluetoothDevices = new ArrayList<BluetoothDevice>();
+
+            // final HIDDeviceManager finalThis = this;
+            // mHandler.postDelayed(new Runnable() {
+            //     @Override
+            //     public void run() {
+            //         finalThis.chromebookConnectionHandler();
+            //     }
+            // }, 5000);
+        }
+    }
+
+    private void shutdownBluetooth() {
+        try {
+            mContext.unregisterReceiver(mBluetoothBroadcast);
+        } catch (Exception e) {
+            // We may not have registered, that's okay
+        }
+    }
+
+    // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly.
+    // This function provides a sort of dummy version of that, watching for changes in the
+    // connected devices and attempting to add controllers as things change.
+    public void chromebookConnectionHandler() {
+        if (!mIsChromebook) {
+            return;
+        }
+
+        ArrayList<BluetoothDevice> disconnected = new ArrayList<BluetoothDevice>();
+        ArrayList<BluetoothDevice> connected = new ArrayList<BluetoothDevice>();
+
+        List<BluetoothDevice> currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT);
+
+        for (BluetoothDevice bluetoothDevice : currentConnected) {
+            if (!mLastBluetoothDevices.contains(bluetoothDevice)) {
+                connected.add(bluetoothDevice);
+            }
+        }
+        for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) {
+            if (!currentConnected.contains(bluetoothDevice)) {
+                disconnected.add(bluetoothDevice);
+            }
+        }
+
+        mLastBluetoothDevices = currentConnected;
+
+        for (BluetoothDevice bluetoothDevice : disconnected) {
+            disconnectBluetoothDevice(bluetoothDevice);
+        }
+        for (BluetoothDevice bluetoothDevice : connected) {
+            connectBluetoothDevice(bluetoothDevice);
+        }
+
+        final HIDDeviceManager finalThis = this;
+        mHandler.postDelayed(new Runnable() {
+            @Override
+            public void run() {
+                finalThis.chromebookConnectionHandler();
+            }
+        }, 10000);
+    }
+
+    public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+        Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice);
+        synchronized (this) {
+            if (mBluetoothDevices.containsKey(bluetoothDevice)) {
+                Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect");
+
+                HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+                device.reconnect();
+
+                return false;
+            }
+            HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice);
+            int id = device.getId();
+            mBluetoothDevices.put(bluetoothDevice, device);
+            mDevicesById.put(id, device);
+
+            // The Steam Controller will mark itself connected once initialization is complete
+        }
+        return true;
+    }
+
+    public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) {
+        synchronized (this) {
+            HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice);
+            if (device == null)
+                return;
+
+            int id = device.getId();
+            mBluetoothDevices.remove(bluetoothDevice);
+            mDevicesById.remove(id);
+            device.shutdown();
+            HIDDeviceDisconnected(id);
+        }
+    }
+
+    public boolean isSteamController(BluetoothDevice bluetoothDevice) {
+        // Sanity check.  If you pass in a null device, by definition it is never a Steam Controller.
+        if (bluetoothDevice == null) {
+            return false;
+        }
+
+        // If the device has no local name, we really don't want to try an equality check against it.
+        if (bluetoothDevice.getName() == null) {
+            return false;
+        }
+
+        return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0);
+    }
+
+    private void close() {
+        shutdownUSB();
+        shutdownBluetooth();
+        synchronized (this) {
+            for (HIDDevice device : mDevicesById.values()) {
+                device.shutdown();
+            }
+            mDevicesById.clear();
+            mBluetoothDevices.clear();
+            HIDDeviceReleaseCallback();
+        }
+    }
+
+    public void setFrozen(boolean frozen) {
+        synchronized (this) {
+            for (HIDDevice device : mDevicesById.values()) {
+                device.setFrozen(frozen);
+            }
+        }        
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private HIDDevice getDevice(int id) {
+        synchronized (this) {
+            HIDDevice result = mDevicesById.get(id);
+            if (result == null) {
+                Log.v(TAG, "No device for id: " + id);
+                Log.v(TAG, "Available devices: " + mDevicesById.keySet());
+            }
+            return result;
+        }
+    }
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    ////////// JNI interface functions
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    public boolean initialize(boolean usb, boolean bluetooth) {
+        Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")");
+
+        if (usb) {
+            initializeUSB();
+        }
+        if (bluetooth) {
+            initializeBluetooth();
+        }
+        return true;
+    }
+
+    public boolean openDevice(int deviceID) {
+        Log.v(TAG, "openDevice deviceID=" + deviceID);
+        HIDDevice device = getDevice(deviceID);
+        if (device == null) {
+            HIDDeviceDisconnected(deviceID);
+            return false;
+        }
+
+        // Look to see if this is a USB device and we have permission to access it
+        UsbDevice usbDevice = device.getDevice();
+        if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) {
+            HIDDeviceOpenPending(deviceID);
+            try {
+                final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31
+                int flags;
+                if (Build.VERSION.SDK_INT >= 31) {
+                    flags = FLAG_MUTABLE;
+                } else {
+                    flags = 0;
+                }
+                mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags));
+            } catch (Exception e) {
+                Log.v(TAG, "Couldn't request permission for USB device " + usbDevice);
+                HIDDeviceOpenResult(deviceID, false);
+            }
+            return false;
+        }
+
+        try {
+            return device.open();
+        } catch (Exception e) {
+            Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+        }
+        return false;
+    }
+
+    public int sendOutputReport(int deviceID, byte[] report) {
+        try {
+            //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length);
+            HIDDevice device;
+            device = getDevice(deviceID);
+            if (device == null) {
+                HIDDeviceDisconnected(deviceID);
+                return -1;
+            }
+
+            return device.sendOutputReport(report);
+        } catch (Exception e) {
+            Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+        }
+        return -1;
+    }
+
+    public int sendFeatureReport(int deviceID, byte[] report) {
+        try {
+            //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length);
+            HIDDevice device;
+            device = getDevice(deviceID);
+            if (device == null) {
+                HIDDeviceDisconnected(deviceID);
+                return -1;
+            }
+
+            return device.sendFeatureReport(report);
+        } catch (Exception e) {
+            Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+        }
+        return -1;
+    }
+
+    public boolean getFeatureReport(int deviceID, byte[] report) {
+        try {
+            //Log.v(TAG, "getFeatureReport deviceID=" + deviceID);
+            HIDDevice device;
+            device = getDevice(deviceID);
+            if (device == null) {
+                HIDDeviceDisconnected(deviceID);
+                return false;
+            }
+
+            return device.getFeatureReport(report);
+        } catch (Exception e) {
+            Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+        }
+        return false;
+    }
+
+    public void closeDevice(int deviceID) {
+        try {
+            Log.v(TAG, "closeDevice deviceID=" + deviceID);
+            HIDDevice device;
+            device = getDevice(deviceID);
+            if (device == null) {
+                HIDDeviceDisconnected(deviceID);
+                return;
+            }
+
+            device.close();
+        } catch (Exception e) {
+            Log.e(TAG, "Got exception: " + Log.getStackTraceString(e));
+        }
+    }
+
+
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+    /////////////// Native methods
+    //////////////////////////////////////////////////////////////////////////////////////////////////////
+
+    private native void HIDDeviceRegisterCallback();
+    private native void HIDDeviceReleaseCallback();
+
+    native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol);
+    native void HIDDeviceOpenPending(int deviceID);
+    native void HIDDeviceOpenResult(int deviceID, boolean opened);
+    native void HIDDeviceDisconnected(int deviceID);
+
+    native void HIDDeviceInputReport(int deviceID, byte[] report);
+    native void HIDDeviceFeatureReport(int deviceID, byte[] report);
+}

+ 309 - 0
android/vcmi-app/src/main/java/org/libsdl/app/HIDDeviceUSB.java

@@ -0,0 +1,309 @@
+package org.libsdl.app;
+
+import android.hardware.usb.*;
+import android.os.Build;
+import android.util.Log;
+import java.util.Arrays;
+
+class HIDDeviceUSB implements HIDDevice {
+
+    private static final String TAG = "hidapi";
+
+    protected HIDDeviceManager mManager;
+    protected UsbDevice mDevice;
+    protected int mInterfaceIndex;
+    protected int mInterface;
+    protected int mDeviceId;
+    protected UsbDeviceConnection mConnection;
+    protected UsbEndpoint mInputEndpoint;
+    protected UsbEndpoint mOutputEndpoint;
+    protected InputThread mInputThread;
+    protected boolean mRunning;
+    protected boolean mFrozen;
+
+    public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) {
+        mManager = manager;
+        mDevice = usbDevice;
+        mInterfaceIndex = interface_index;
+        mInterface = mDevice.getInterface(mInterfaceIndex).getId();
+        mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier());
+        mRunning = false;
+    }
+
+    public String getIdentifier() {
+        return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex);
+    }
+
+    @Override
+    public int getId() {
+        return mDeviceId;
+    }
+
+    @Override
+    public int getVendorId() {
+        return mDevice.getVendorId();
+    }
+
+    @Override
+    public int getProductId() {
+        return mDevice.getProductId();
+    }
+
+    @Override
+    public String getSerialNumber() {
+        String result = null;
+        if (Build.VERSION.SDK_INT >= 21) {
+            try {
+                result = mDevice.getSerialNumber();
+            }
+            catch (SecurityException exception) {
+                //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage());
+            }
+        }
+        if (result == null) {
+            result = "";
+        }
+        return result;
+    }
+
+    @Override
+    public int getVersion() {
+        return 0;
+    }
+
+    @Override
+    public String getManufacturerName() {
+        String result = null;
+        if (Build.VERSION.SDK_INT >= 21) {
+            result = mDevice.getManufacturerName();
+        }
+        if (result == null) {
+            result = String.format("%x", getVendorId());
+        }
+        return result;
+    }
+
+    @Override
+    public String getProductName() {
+        String result = null;
+        if (Build.VERSION.SDK_INT >= 21) {
+            result = mDevice.getProductName();
+        }
+        if (result == null) {
+            result = String.format("%x", getProductId());
+        }
+        return result;
+    }
+
+    @Override
+    public UsbDevice getDevice() {
+        return mDevice;
+    }
+
+    public String getDeviceName() {
+        return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")";
+    }
+
+    @Override
+    public boolean open() {
+        mConnection = mManager.getUSBManager().openDevice(mDevice);
+        if (mConnection == null) {
+            Log.w(TAG, "Unable to open USB device " + getDeviceName());
+            return false;
+        }
+
+        // Force claim our interface
+        UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+        if (!mConnection.claimInterface(iface, true)) {
+            Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName());
+            close();
+            return false;
+        }
+
+        // Find the endpoints
+        for (int j = 0; j < iface.getEndpointCount(); j++) {
+            UsbEndpoint endpt = iface.getEndpoint(j);
+            switch (endpt.getDirection()) {
+            case UsbConstants.USB_DIR_IN:
+                if (mInputEndpoint == null) {
+                    mInputEndpoint = endpt;
+                }
+                break;
+            case UsbConstants.USB_DIR_OUT:
+                if (mOutputEndpoint == null) {
+                    mOutputEndpoint = endpt;
+                }
+                break;
+            }
+        }
+
+        // Make sure the required endpoints were present
+        if (mInputEndpoint == null || mOutputEndpoint == null) {
+            Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName());
+            close();
+            return false;
+        }
+
+        // Start listening for input
+        mRunning = true;
+        mInputThread = new InputThread();
+        mInputThread.start();
+
+        return true;
+    }
+
+    @Override
+    public int sendFeatureReport(byte[] report) {
+        int res = -1;
+        int offset = 0;
+        int length = report.length;
+        boolean skipped_report_id = false;
+        byte report_number = report[0];
+
+        if (report_number == 0x0) {
+            ++offset;
+            --length;
+            skipped_report_id = true;
+        }
+
+        res = mConnection.controlTransfer(
+            UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT,
+            0x09/*HID set_report*/,
+            (3/*HID feature*/ << 8) | report_number,
+            mInterface,
+            report, offset, length,
+            1000/*timeout millis*/);
+
+        if (res < 0) {
+            Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName());
+            return -1;
+        }
+
+        if (skipped_report_id) {
+            ++length;
+        }
+        return length;
+    }
+
+    @Override
+    public int sendOutputReport(byte[] report) {
+        int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000);
+        if (r != report.length) {
+            Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName());
+        }
+        return r;
+    }
+
+    @Override
+    public boolean getFeatureReport(byte[] report) {
+        int res = -1;
+        int offset = 0;
+        int length = report.length;
+        boolean skipped_report_id = false;
+        byte report_number = report[0];
+
+        if (report_number == 0x0) {
+            /* Offset the return buffer by 1, so that the report ID
+               will remain in byte 0. */
+            ++offset;
+            --length;
+            skipped_report_id = true;
+        }
+
+        res = mConnection.controlTransfer(
+            UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN,
+            0x01/*HID get_report*/,
+            (3/*HID feature*/ << 8) | report_number,
+            mInterface,
+            report, offset, length,
+            1000/*timeout millis*/);
+
+        if (res < 0) {
+            Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName());
+            return false;
+        }
+
+        if (skipped_report_id) {
+            ++res;
+            ++length;
+        }
+
+        byte[] data;
+        if (res == length) {
+            data = report;
+        } else {
+            data = Arrays.copyOfRange(report, 0, res);
+        }
+        mManager.HIDDeviceFeatureReport(mDeviceId, data);
+
+        return true;
+    }
+
+    @Override
+    public void close() {
+        mRunning = false;
+        if (mInputThread != null) {
+            while (mInputThread.isAlive()) {
+                mInputThread.interrupt();
+                try {
+                    mInputThread.join();
+                } catch (InterruptedException e) {
+                    // Keep trying until we're done
+                }
+            }
+            mInputThread = null;
+        }
+        if (mConnection != null) {
+            UsbInterface iface = mDevice.getInterface(mInterfaceIndex);
+            mConnection.releaseInterface(iface);
+            mConnection.close();
+            mConnection = null;
+        }
+    }
+
+    @Override
+    public void shutdown() {
+        close();
+        mManager = null;
+    }
+
+    @Override
+    public void setFrozen(boolean frozen) {
+        mFrozen = frozen;
+    }
+
+    protected class InputThread extends Thread {
+        @Override
+        public void run() {
+            int packetSize = mInputEndpoint.getMaxPacketSize();
+            byte[] packet = new byte[packetSize];
+            while (mRunning) {
+                int r;
+                try
+                {
+                    r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000);
+                }
+                catch (Exception e)
+                {
+                    Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e);
+                    break;
+                }
+                if (r < 0) {
+                    // Could be a timeout or an I/O error
+                }
+                if (r > 0) {
+                    byte[] data;
+                    if (r == packetSize) {
+                        data = packet;
+                    } else {
+                        data = Arrays.copyOfRange(packet, 0, r);
+                    }
+
+                    if (!mFrozen) {
+                        mManager.HIDDeviceInputReport(mDeviceId, data);
+                    }
+                }
+            }
+        }
+    }
+}

+ 87 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDL.java

@@ -0,0 +1,87 @@
+package org.libsdl.app;
+
+import android.content.Context;
+
+import java.lang.Class;
+import java.lang.reflect.Method;
+
+import eu.vcmi.vcmi.NativeMethods;
+
+/**
+    SDL library initialization
+*/
+public class SDL {
+
+    // This function should be called first and sets up the native code
+    // so it can call into the Java classes
+    public static void setupJNI() {
+        SDLActivity.nativeSetupJNI();
+        SDLAudioManager.nativeSetupJNI();
+        SDLControllerManager.nativeSetupJNI();
+    }
+
+    // This function should be called each time the activity is started
+    public static void initialize() {
+        setContext(null);
+
+        SDLActivity.initialize();
+        SDLAudioManager.initialize();
+        SDLControllerManager.initialize();
+    }
+
+    // This function stores the current activity (SDL or not)
+    public static void setContext(Context context) {
+        mContext = context;
+    }
+
+    public static Context getContext() {
+        return mContext;
+    }
+
+    public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException {
+
+        if (libraryName == null) {
+            throw new NullPointerException("No library name provided.");
+        }
+
+        try {
+            // Let's see if we have ReLinker available in the project.  This is necessary for 
+            // some projects that have huge numbers of local libraries bundled, and thus may 
+            // trip a bug in Android's native library loader which ReLinker works around.  (If
+            // loadLibrary works properly, ReLinker will simply use the normal Android method
+            // internally.)
+            //
+            // To use ReLinker, just add it as a dependency.  For more information, see 
+            // https://github.com/KeepSafe/ReLinker for ReLinker's repository.
+            //
+            Class<?> relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker");
+            Class<?> relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener");
+            Class<?> contextClass = mContext.getClassLoader().loadClass("android.content.Context");
+            Class<?> stringClass = mContext.getClassLoader().loadClass("java.lang.String");
+
+            // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if 
+            // they've changed during updates.
+            Method forceMethod = relinkClass.getDeclaredMethod("force");
+            Object relinkInstance = forceMethod.invoke(null);
+            Class<?> relinkInstanceClass = relinkInstance.getClass();
+
+            // Actually load the library!
+            Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass);
+            loadMethod.invoke(relinkInstance, mContext, libraryName, null, null);
+        }
+        catch (final Throwable e) {
+            // Fall back
+            try {
+                System.loadLibrary(libraryName);
+            }
+            catch (final UnsatisfiedLinkError ule) {
+                throw ule;
+            }
+            catch (final SecurityException se) {
+                throw se;
+            }
+        }
+    }
+
+    protected static Context mContext;
+}

+ 1931 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDLActivity.java

@@ -0,0 +1,1931 @@
+package org.libsdl.app;
+
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.Dialog;
+import android.app.UiModeManager;
+import android.content.ClipboardManager;
+import android.content.ClipData;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.ApplicationInfo;
+import android.content.pm.PackageManager;
+import android.content.res.Configuration;
+import android.graphics.Bitmap;
+import android.graphics.Color;
+import android.graphics.PorterDuff;
+import android.graphics.drawable.Drawable;
+import android.hardware.Sensor;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Message;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Selection;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.util.SparseArray;
+import android.view.Display;
+import android.view.Gravity;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.PointerIcon;
+import android.view.Surface;
+import android.view.View;
+import android.view.ViewGroup;
+import android.view.Window;
+import android.view.WindowManager;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
+import android.widget.Button;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.RelativeLayout;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import java.util.Hashtable;
+import java.util.Locale;
+
+import eu.vcmi.vcmi.util.LibsLoader;
+
+
+/**
+    SDL Activity
+*/
+public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener {
+    private static final String TAG = "SDL";
+    private static final int SDL_MAJOR_VERSION = 2;
+    private static final int SDL_MINOR_VERSION = 26;
+    private static final int SDL_MICRO_VERSION = 1;
+/*
+    // Display InputType.SOURCE/CLASS of events and devices
+    //
+    // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]");
+    // SDLActivity.debugSource(event.getSource(), "event");
+    public static void debugSource(int sources, String prefix) {
+        int s = sources;
+        int s_copy = sources;
+        String cls = "";
+        String src = "";
+        int tst = 0;
+        int FLAG_TAINTED = 0x80000000;
+
+        if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0)     cls += " BUTTON";
+        if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0)   cls += " JOYSTICK";
+        if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0)    cls += " POINTER";
+        if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0)   cls += " POSITION";
+        if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0)  cls += " TRACKBALL";
+
+
+        int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits
+        s2 &= ~(  InputDevice.SOURCE_CLASS_BUTTON
+                | InputDevice.SOURCE_CLASS_JOYSTICK
+                | InputDevice.SOURCE_CLASS_POINTER
+                | InputDevice.SOURCE_CLASS_POSITION
+                | InputDevice.SOURCE_CLASS_TRACKBALL);
+
+        if (s2 != 0) cls += "Some_Unkown";
+
+        s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class;
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+            tst = InputDevice.SOURCE_BLUETOOTH_STYLUS;
+            if ((s & tst) == tst) src += " BLUETOOTH_STYLUS";
+            s2 &= ~tst;
+        }
+
+        tst = InputDevice.SOURCE_DPAD;
+        if ((s & tst) == tst) src += " DPAD";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_GAMEPAD;
+        if ((s & tst) == tst) src += " GAMEPAD";
+        s2 &= ~tst;
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
+            tst = InputDevice.SOURCE_HDMI;
+            if ((s & tst) == tst) src += " HDMI";
+            s2 &= ~tst;
+        }
+
+        tst = InputDevice.SOURCE_JOYSTICK;
+        if ((s & tst) == tst) src += " JOYSTICK";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_KEYBOARD;
+        if ((s & tst) == tst) src += " KEYBOARD";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_MOUSE;
+        if ((s & tst) == tst) src += " MOUSE";
+        s2 &= ~tst;
+
+        if (Build.VERSION.SDK_INT >= 26) {
+            tst = InputDevice.SOURCE_MOUSE_RELATIVE;
+            if ((s & tst) == tst) src += " MOUSE_RELATIVE";
+            s2 &= ~tst;
+
+            tst = InputDevice.SOURCE_ROTARY_ENCODER;
+            if ((s & tst) == tst) src += " ROTARY_ENCODER";
+            s2 &= ~tst;
+        }
+        tst = InputDevice.SOURCE_STYLUS;
+        if ((s & tst) == tst) src += " STYLUS";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_TOUCHPAD;
+        if ((s & tst) == tst) src += " TOUCHPAD";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_TOUCHSCREEN;
+        if ((s & tst) == tst) src += " TOUCHSCREEN";
+        s2 &= ~tst;
+
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
+            tst = InputDevice.SOURCE_TOUCH_NAVIGATION;
+            if ((s & tst) == tst) src += " TOUCH_NAVIGATION";
+            s2 &= ~tst;
+        }
+
+        tst = InputDevice.SOURCE_TRACKBALL;
+        if ((s & tst) == tst) src += " TRACKBALL";
+        s2 &= ~tst;
+
+        tst = InputDevice.SOURCE_ANY;
+        if ((s & tst) == tst) src += " ANY";
+        s2 &= ~tst;
+
+        if (s == FLAG_TAINTED) src += " FLAG_TAINTED";
+        s2 &= ~FLAG_TAINTED;
+
+        if (s2 != 0) src += " Some_Unkown";
+
+        Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src);
+    }
+*/
+
+    public static boolean mIsResumedCalled, mHasFocus;
+    public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24);
+
+    // Cursor types
+    // private static final int SDL_SYSTEM_CURSOR_NONE = -1;
+    private static final int SDL_SYSTEM_CURSOR_ARROW = 0;
+    private static final int SDL_SYSTEM_CURSOR_IBEAM = 1;
+    private static final int SDL_SYSTEM_CURSOR_WAIT = 2;
+    private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3;
+    private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4;
+    private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5;
+    private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6;
+    private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7;
+    private static final int SDL_SYSTEM_CURSOR_SIZENS = 8;
+    private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9;
+    private static final int SDL_SYSTEM_CURSOR_NO = 10;
+    private static final int SDL_SYSTEM_CURSOR_HAND = 11;
+
+    protected static final int SDL_ORIENTATION_UNKNOWN = 0;
+    protected static final int SDL_ORIENTATION_LANDSCAPE = 1;
+    protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2;
+    protected static final int SDL_ORIENTATION_PORTRAIT = 3;
+    protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4;
+
+    protected static int mCurrentOrientation;
+    protected static Locale mCurrentLocale;
+
+    // Handle the state of the native layer
+    public enum NativeState {
+           INIT, RESUMED, PAUSED
+    }
+
+    public static NativeState mNextNativeState;
+    public static NativeState mCurrentNativeState;
+
+    /** If shared libraries (e.g. SDL or the native application) could not be loaded. */
+    public static boolean mBrokenLibraries = true;
+
+    // Main components
+    protected static SDLActivity mSingleton;
+    protected static SDLSurface mSurface;
+    protected static DummyEdit mTextEdit;
+    protected static boolean mScreenKeyboardShown;
+    protected static ViewGroup mLayout;
+    protected static SDLClipboardHandler mClipboardHandler;
+    protected static Hashtable<Integer, PointerIcon> mCursors;
+    protected static int mLastCursorID;
+    protected static SDLGenericMotionListener_API12 mMotionListener;
+    protected static HIDDeviceManager mHIDDeviceManager;
+
+    // This is what SDL runs in. It invokes SDL_main(), eventually
+    protected static Thread mSDLThread;
+
+    protected static SDLGenericMotionListener_API12 getMotionListener() {
+        if (mMotionListener == null) {
+            if (Build.VERSION.SDK_INT >= 26) {
+                mMotionListener = new SDLGenericMotionListener_API26();
+            } else if (Build.VERSION.SDK_INT >= 24) {
+                mMotionListener = new SDLGenericMotionListener_API24();
+            } else {
+                mMotionListener = new SDLGenericMotionListener_API12();
+            }
+        }
+
+        return mMotionListener;
+    }
+
+    /**
+     * This method returns the name of the shared object with the application entry point
+     * It can be overridden by derived classes.
+     */
+    protected String getMainSharedObject() {
+        String library = "libvcmi-client.so";
+
+        return getContext().getApplicationInfo().nativeLibraryDir + "/" + library;
+    }
+
+    /**
+     * This method returns the name of the application entry point
+     * It can be overridden by derived classes.
+     */
+    protected String getMainFunction() {
+        return "SDL_main";
+    }
+
+    /**
+     * This method is called by SDL before loading the native shared libraries.
+     * It can be overridden to provide names of shared libraries to be loaded.
+     * The default implementation returns the defaults. It never returns null.
+     * An array returned by a new implementation must at least contain "SDL2".
+     * Also keep in mind that the order the libraries are loaded may matter.
+     * @return names of shared libraries to be loaded (e.g. "SDL2", "main").
+     */
+    protected String[] getLibraries() {
+        return new String[] {
+                "SDL2",
+                // "SDL2_image",
+                // "SDL2_mixer",
+                // "SDL2_net",
+                // "SDL2_ttf",
+                "main"
+        };
+    }
+
+    // Load the .so
+    public void loadLibraries() {
+        for (String lib : getLibraries()) {
+            SDL.loadLibrary(lib);
+        }
+    }
+
+    /**
+     * This method is called by SDL before starting the native application thread.
+     * It can be overridden to provide the arguments after the application name.
+     * The default implementation returns an empty array. It never returns null.
+     * @return arguments for the native application.
+     */
+    protected String[] getArguments() {
+        return new String[0];
+    }
+
+    public static void initialize() {
+        // The static nature of the singleton and Android quirkyness force us to initialize everything here
+        // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values
+        mSingleton = null;
+        mSurface = null;
+        mTextEdit = null;
+        mLayout = null;
+        mClipboardHandler = null;
+        mCursors = new Hashtable<Integer, PointerIcon>();
+        mLastCursorID = 0;
+        mSDLThread = null;
+        mIsResumedCalled = false;
+        mHasFocus = true;
+        mNextNativeState = NativeState.INIT;
+        mCurrentNativeState = NativeState.INIT;
+    }
+    
+    protected SDLSurface createSDLSurface(Context context) {
+        return new SDLSurface(context);
+    }
+
+    // Setup
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        Log.v(TAG, "Device: " + Build.DEVICE);
+        Log.v(TAG, "Model: " + Build.MODEL);
+        Log.v(TAG, "onCreate()");
+        super.onCreate(savedInstanceState);
+
+        try {
+            Thread.currentThread().setName("SDLActivity");
+        } catch (Exception e) {
+            Log.v(TAG, "modify thread properties failed " + e.toString());
+        }
+
+        // Load shared libraries
+        String errorMsgBrokenLib = "";
+        try {
+            loadLibraries();
+            mBrokenLibraries = false; /* success */
+        } catch(UnsatisfiedLinkError e) {
+            System.err.println(e.getMessage());
+            mBrokenLibraries = true;
+            errorMsgBrokenLib = e.getMessage();
+        } catch(Exception e) {
+            System.err.println(e.getMessage());
+            mBrokenLibraries = true;
+            errorMsgBrokenLib = e.getMessage();
+        }
+
+        if (!mBrokenLibraries) {
+            String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." +
+                                      String.valueOf(SDL_MINOR_VERSION) + "." +
+                                      String.valueOf(SDL_MICRO_VERSION);
+            String version = nativeGetVersion();
+            if (!version.equals(expected_version)) {
+                mBrokenLibraries = true;
+                errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")";
+            }
+        }
+
+        if (mBrokenLibraries) {
+            mSingleton = this;
+            AlertDialog.Builder dlgAlert  = new AlertDialog.Builder(this);
+            dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall."
+                  + System.getProperty("line.separator")
+                  + System.getProperty("line.separator")
+                  + "Error: " + errorMsgBrokenLib);
+            dlgAlert.setTitle("SDL Error");
+            dlgAlert.setPositiveButton("Exit",
+                new DialogInterface.OnClickListener() {
+                    @Override
+                    public void onClick(DialogInterface dialog,int id) {
+                        // if this button is clicked, close current activity
+                        SDLActivity.mSingleton.finish();
+                    }
+                });
+           dlgAlert.setCancelable(false);
+           dlgAlert.create().show();
+
+           return;
+        }
+
+        // Set up JNI
+        SDL.setupJNI();
+
+        // Initialize state
+        SDL.initialize();
+        SDL.setContext(this);
+
+        // So we can call stuff from static callbacks
+        mSingleton = this;
+
+        mClipboardHandler = new SDLClipboardHandler();
+
+        mHIDDeviceManager = HIDDeviceManager.acquire(this);
+
+        // Set up the surface
+        mSurface = createSDLSurface(getApplication());
+
+        mLayout = new RelativeLayout(this);
+        mLayout.addView(mSurface);
+
+        // Get our current screen orientation and pass it down.
+        mCurrentOrientation = SDLActivity.getCurrentOrientation();
+        // Only record current orientation
+        SDLActivity.onNativeOrientationChanged(mCurrentOrientation);
+
+        try {
+            if (Build.VERSION.SDK_INT < 24) {
+                mCurrentLocale = getContext().getResources().getConfiguration().locale;
+            } else {
+                mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0);
+            }
+        } catch(Exception ignored) {
+        }
+
+        setContentView(mLayout);
+
+        setWindowStyle(false);
+
+        getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this);
+
+        // Get filename from "Open with" of another application
+        Intent intent = getIntent();
+        if (intent != null && intent.getData() != null) {
+            String filename = intent.getData().getPath();
+            if (filename != null) {
+                Log.v(TAG, "Got filename: " + filename);
+                SDLActivity.onNativeDropFile(filename);
+            }
+        }
+    }
+
+    protected void pauseNativeThread() {
+        mNextNativeState = NativeState.PAUSED;
+        mIsResumedCalled = false;
+
+        if (SDLActivity.mBrokenLibraries) {
+            return;
+        }
+
+        SDLActivity.handleNativeState();
+    }
+
+    protected void resumeNativeThread() {
+        mNextNativeState = NativeState.RESUMED;
+        mIsResumedCalled = true;
+
+        if (SDLActivity.mBrokenLibraries) {
+           return;
+        }
+
+        SDLActivity.handleNativeState();
+    }
+
+    // Events
+    @Override
+    protected void onPause() {
+        Log.v(TAG, "onPause()");
+        super.onPause();
+
+        if (mHIDDeviceManager != null) {
+            mHIDDeviceManager.setFrozen(true);
+        }
+        if (!mHasMultiWindow) {
+            pauseNativeThread();
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        Log.v(TAG, "onResume()");
+        super.onResume();
+
+        if (mHIDDeviceManager != null) {
+            mHIDDeviceManager.setFrozen(false);
+        }
+        if (!mHasMultiWindow) {
+            resumeNativeThread();
+        }
+    }
+
+    @Override
+    protected void onStop() {
+        Log.v(TAG, "onStop()");
+        super.onStop();
+        if (mHasMultiWindow) {
+            pauseNativeThread();
+        }
+    }
+
+    @Override
+    protected void onStart() {
+        Log.v(TAG, "onStart()");
+        super.onStart();
+        if (mHasMultiWindow) {
+            resumeNativeThread();
+        }
+    }
+
+    public static int getCurrentOrientation() {
+        int result = SDL_ORIENTATION_UNKNOWN;
+
+        Activity activity = (Activity)getContext();
+        if (activity == null) {
+            return result;
+        }
+        Display display = activity.getWindowManager().getDefaultDisplay();
+
+        switch (display.getRotation()) {
+            case Surface.ROTATION_0:
+                result = SDL_ORIENTATION_PORTRAIT;
+                break;
+
+            case Surface.ROTATION_90:
+                result = SDL_ORIENTATION_LANDSCAPE;
+                break;
+
+            case Surface.ROTATION_180:
+                result = SDL_ORIENTATION_PORTRAIT_FLIPPED;
+                break;
+
+            case Surface.ROTATION_270:
+                result = SDL_ORIENTATION_LANDSCAPE_FLIPPED;
+                break;
+        }
+
+        return result;
+    }
+
+    @Override
+    public void onWindowFocusChanged(boolean hasFocus) {
+        super.onWindowFocusChanged(hasFocus);
+        Log.v(TAG, "onWindowFocusChanged(): " + hasFocus);
+
+        if (SDLActivity.mBrokenLibraries) {
+           return;
+        }
+
+        mHasFocus = hasFocus;
+        if (hasFocus) {
+           mNextNativeState = NativeState.RESUMED;
+           SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded();
+
+           SDLActivity.handleNativeState();
+           nativeFocusChanged(true);
+
+        } else {
+           nativeFocusChanged(false);
+           if (!mHasMultiWindow) {
+               mNextNativeState = NativeState.PAUSED;
+               SDLActivity.handleNativeState();
+           }
+        }
+    }
+
+    @Override
+    public void onLowMemory() {
+        Log.v(TAG, "onLowMemory()");
+        super.onLowMemory();
+
+        if (SDLActivity.mBrokenLibraries) {
+           return;
+        }
+
+        SDLActivity.nativeLowMemory();
+    }
+
+    @Override
+    public void onConfigurationChanged(Configuration newConfig) {
+        Log.v(TAG, "onConfigurationChanged()");
+        super.onConfigurationChanged(newConfig);
+
+        if (SDLActivity.mBrokenLibraries) {
+           return;
+        }
+
+        if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) {
+            mCurrentLocale = newConfig.locale;
+            SDLActivity.onNativeLocaleChanged();
+        }
+    }
+
+    @Override
+    protected void onDestroy() {
+        Log.v(TAG, "onDestroy()");
+
+        if (mHIDDeviceManager != null) {
+            HIDDeviceManager.release(mHIDDeviceManager);
+            mHIDDeviceManager = null;
+        }
+
+        if (SDLActivity.mBrokenLibraries) {
+           super.onDestroy();
+           return;
+        }
+
+        if (SDLActivity.mSDLThread != null) {
+
+            // Send Quit event to "SDLThread" thread
+            SDLActivity.nativeSendQuit();
+
+            // Wait for "SDLThread" thread to end
+            try {
+                SDLActivity.mSDLThread.join();
+            } catch(Exception e) {
+                Log.v(TAG, "Problem stopping SDLThread: " + e);
+            }
+        }
+
+        SDLActivity.nativeQuit();
+
+        super.onDestroy();
+    }
+
+    @Override
+    public void onBackPressed() {
+        // Check if we want to block the back button in case of mouse right click.
+        //
+        // If we do, the normal hardware back button will no longer work and people have to use home,
+        // but the mouse right click will work.
+        //
+        boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false);
+        if (trapBack) {
+            // Exit and let the mouse handler handle this button (if appropriate)
+            return;
+        }
+
+        // Default system back button behavior.
+        if (!isFinishing()) {
+            super.onBackPressed();
+        }
+    }
+
+    // Called by JNI from SDL.
+    public static void manualBackButton() {
+        mSingleton.pressBackButton();
+    }
+
+    // Used to get us onto the activity's main thread
+    public void pressBackButton() {
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                if (!SDLActivity.this.isFinishing()) {
+                    SDLActivity.this.superOnBackPressed();
+                }
+            }
+        });
+    }
+
+    // Used to access the system back behavior.
+    public void superOnBackPressed() {
+        super.onBackPressed();
+    }
+
+    @Override
+    public boolean dispatchKeyEvent(KeyEvent event) {
+
+        if (SDLActivity.mBrokenLibraries) {
+           return false;
+        }
+
+        int keyCode = event.getKeyCode();
+        // Ignore certain special keys so they're handled by Android
+        if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
+            keyCode == KeyEvent.KEYCODE_VOLUME_UP ||
+            keyCode == KeyEvent.KEYCODE_CAMERA ||
+            keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */
+            keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */
+            ) {
+            return false;
+        }
+        return super.dispatchKeyEvent(event);
+    }
+
+    /* Transition to next state */
+    public static void handleNativeState() {
+
+        if (mNextNativeState == mCurrentNativeState) {
+            // Already in same state, discard.
+            return;
+        }
+
+        // Try a transition to init state
+        if (mNextNativeState == NativeState.INIT) {
+
+            mCurrentNativeState = mNextNativeState;
+            return;
+        }
+
+        // Try a transition to paused state
+        if (mNextNativeState == NativeState.PAUSED) {
+            if (mSDLThread != null) {
+                nativePause();
+            }
+            if (mSurface != null) {
+                mSurface.handlePause();
+            }
+            mCurrentNativeState = mNextNativeState;
+            return;
+        }
+
+        // Try a transition to resumed state
+        if (mNextNativeState == NativeState.RESUMED) {
+            if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) {
+                if (mSDLThread == null) {
+                    // This is the entry point to the C app.
+                    // Start up the C app thread and enable sensor input for the first time
+                    // FIXME: Why aren't we enabling sensor input at start?
+
+                    mSDLThread = new Thread(new SDLMain(), "SDLThread");
+                    mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+                    mSDLThread.start();
+
+                    // No nativeResume(), don't signal Android_ResumeSem
+                } else {
+                    nativeResume();
+                }
+                mSurface.handleResume();
+
+                mCurrentNativeState = mNextNativeState;
+            }
+        }
+    }
+
+    // Messages from the SDLMain thread
+    static final int COMMAND_CHANGE_TITLE = 1;
+    static final int COMMAND_CHANGE_WINDOW_STYLE = 2;
+    static final int COMMAND_TEXTEDIT_HIDE = 3;
+    static final int COMMAND_SET_KEEP_SCREEN_ON = 5;
+
+    protected static final int COMMAND_USER = 0x8000;
+
+    protected static boolean mFullscreenModeActive;
+
+    /**
+     * This method is called by SDL if SDL did not handle a message itself.
+     * This happens if a received message contains an unsupported command.
+     * Method can be overwritten to handle Messages in a different class.
+     * @param command the command of the message.
+     * @param param the parameter of the message. May be null.
+     * @return if the message was handled in overridden method.
+     */
+    protected boolean onUnhandledMessage(int command, Object param) {
+        return false;
+    }
+
+    /**
+     * A Handler class for Messages from native SDL applications.
+     * It uses current Activities as target (e.g. for the title).
+     * static to prevent implicit references to enclosing object.
+     */
+    protected static class SDLCommandHandler extends Handler {
+        @Override
+        public void handleMessage(Message msg) {
+            Context context = SDL.getContext();
+            if (context == null) {
+                Log.e(TAG, "error handling message, getContext() returned null");
+                return;
+            }
+            switch (msg.arg1) {
+            case COMMAND_CHANGE_TITLE:
+                if (context instanceof Activity) {
+                    ((Activity) context).setTitle((String)msg.obj);
+                } else {
+                    Log.e(TAG, "error handling message, getContext() returned no Activity");
+                }
+                break;
+            case COMMAND_CHANGE_WINDOW_STYLE:
+                if (Build.VERSION.SDK_INT >= 19) {
+                    if (context instanceof Activity) {
+                        Window window = ((Activity) context).getWindow();
+                        if (window != null) {
+                            if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+                                int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+                                        View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+                                        View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+                                        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+                                        View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+                                        View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+                                window.getDecorView().setSystemUiVisibility(flags);
+                                window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+                                window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+                                SDLActivity.mFullscreenModeActive = true;
+                            } else {
+                                int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE;
+                                window.getDecorView().setSystemUiVisibility(flags);
+                                window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN);
+                                window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
+                                SDLActivity.mFullscreenModeActive = false;
+                            }
+                        }
+                    } else {
+                        Log.e(TAG, "error handling message, getContext() returned no Activity");
+                    }
+                }
+                break;
+            case COMMAND_TEXTEDIT_HIDE:
+                if (mTextEdit != null) {
+                    // Note: On some devices setting view to GONE creates a flicker in landscape.
+                    // Setting the View's sizes to 0 is similar to GONE but without the flicker.
+                    // The sizes will be set to useful values when the keyboard is shown again.
+                    mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0));
+
+                    InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);
+                    imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0);
+
+                    mScreenKeyboardShown = false;
+
+                    mSurface.requestFocus();
+                }
+                break;
+            case COMMAND_SET_KEEP_SCREEN_ON:
+            {
+                if (context instanceof Activity) {
+                    Window window = ((Activity) context).getWindow();
+                    if (window != null) {
+                        if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) {
+                            window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+                        } else {
+                            window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+                        }
+                    }
+                }
+                break;
+            }
+            default:
+                if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) {
+                    Log.e(TAG, "error handling message, command is " + msg.arg1);
+                }
+            }
+        }
+    }
+
+    // Handler for the messages
+    Handler commandHandler = new SDLCommandHandler();
+
+    // Send a message from the SDLMain thread
+    boolean sendCommand(int command, Object data) {
+        Message msg = commandHandler.obtainMessage();
+        msg.arg1 = command;
+        msg.obj = data;
+        boolean result = commandHandler.sendMessage(msg);
+
+        if (Build.VERSION.SDK_INT >= 19) {
+            if (command == COMMAND_CHANGE_WINDOW_STYLE) {
+                // Ensure we don't return until the resize has actually happened,
+                // or 500ms have passed.
+
+                boolean bShouldWait = false;
+
+                if (data instanceof Integer) {
+                    // Let's figure out if we're already laid out fullscreen or not.
+                    Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+                    DisplayMetrics realMetrics = new DisplayMetrics();
+                    display.getRealMetrics(realMetrics);
+
+                    boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) &&
+                            (realMetrics.heightPixels == mSurface.getHeight()));
+
+                    if ((Integer) data == 1) {
+                        // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going
+                        // to change size and should wait for surfaceChanged() before we return, so the size
+                        // is right back in native code.  If we're already laid out fullscreen, though, we're
+                        // not going to change size even if we change decor modes, so we shouldn't wait for
+                        // surfaceChanged() -- which may not even happen -- and should return immediately.
+                        bShouldWait = !bFullscreenLayout;
+                    } else {
+                        // If we're laid out fullscreen (even if the status bar and nav bar are present),
+                        // or are actively in fullscreen, we're going to change size and should wait for
+                        // surfaceChanged before we return, so the size is right back in native code.
+                        bShouldWait = bFullscreenLayout;
+                    }
+                }
+
+                if (bShouldWait && (SDLActivity.getContext() != null)) {
+                    // We'll wait for the surfaceChanged() method, which will notify us
+                    // when called.  That way, we know our current size is really the
+                    // size we need, instead of grabbing a size that's still got
+                    // the navigation and/or status bars before they're hidden.
+                    //
+                    // We'll wait for up to half a second, because some devices
+                    // take a surprisingly long time for the surface resize, but
+                    // then we'll just give up and return.
+                    //
+                    synchronized (SDLActivity.getContext()) {
+                        try {
+                            SDLActivity.getContext().wait(500);
+                        } catch (InterruptedException ie) {
+                            ie.printStackTrace();
+                        }
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
+
+    // C functions we call
+    public static native String nativeGetVersion();
+    public static native int nativeSetupJNI();
+    public static native int nativeRunMain(String library, String function, Object arguments);
+    public static native void nativeLowMemory();
+    public static native void nativeSendQuit();
+    public static native void nativeQuit();
+    public static native void nativePause();
+    public static native void nativeResume();
+    public static native void nativeFocusChanged(boolean hasFocus);
+    public static native void onNativeDropFile(String filename);
+    public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate);
+    public static native void onNativeResize();
+    public static native void onNativeKeyDown(int keycode);
+    public static native void onNativeKeyUp(int keycode);
+    public static native boolean onNativeSoftReturnKey();
+    public static native void onNativeKeyboardFocusLost();
+    public static native void onNativeMouse(int button, int action, float x, float y, boolean relative);
+    public static native void onNativeTouch(int touchDevId, int pointerFingerId,
+                                            int action, float x,
+                                            float y, float p);
+    public static native void onNativeAccel(float x, float y, float z);
+    public static native void onNativeClipboardChanged();
+    public static native void onNativeSurfaceCreated();
+    public static native void onNativeSurfaceChanged();
+    public static native void onNativeSurfaceDestroyed();
+    public static native String nativeGetHint(String name);
+    public static native boolean nativeGetHintBoolean(String name, boolean default_value);
+    public static native void nativeSetenv(String name, String value);
+    public static native void onNativeOrientationChanged(int orientation);
+    public static native void nativeAddTouch(int touchId, String name);
+    public static native void nativePermissionResult(int requestCode, boolean result);
+    public static native void onNativeLocaleChanged();
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean setActivityTitle(String title) {
+        // Called from SDLMain() thread and can't directly affect the view
+        return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void setWindowStyle(boolean fullscreen) {
+        // Called from SDLMain() thread and can't directly affect the view
+        mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     * This is a static method for JNI convenience, it calls a non-static method
+     * so that is can be overridden
+     */
+    public static void setOrientation(int w, int h, boolean resizable, String hint)
+    {
+        if (mSingleton != null) {
+            mSingleton.setOrientationBis(w, h, resizable, hint);
+        }
+    }
+
+    /**
+     * This can be overridden
+     */
+    public void setOrientationBis(int w, int h, boolean resizable, String hint)
+    {
+        int orientation_landscape = -1;
+        int orientation_portrait = -1;
+
+        /* If set, hint "explicitly controls which UI orientations are allowed". */
+        if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) {
+            orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+        } else if (hint.contains("LandscapeRight")) {
+            orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE;
+        } else if (hint.contains("LandscapeLeft")) {
+            orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE;
+        }
+
+        if (hint.contains("Portrait") && hint.contains("PortraitUpsideDown")) {
+            orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT;
+        } else if (hint.contains("Portrait")) {
+            orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT;
+        } else if (hint.contains("PortraitUpsideDown")) {
+            orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT;
+        }
+
+        boolean is_landscape_allowed = (orientation_landscape != -1);
+        boolean is_portrait_allowed = (orientation_portrait != -1);
+        int req; /* Requested orientation */
+
+        /* No valid hint, nothing is explicitly allowed */
+        if (!is_portrait_allowed && !is_landscape_allowed) {
+            if (resizable) {
+                /* All orientations are allowed */
+                req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+            } else {
+                /* Fixed window and nothing specified. Get orientation from w/h of created window */
+                req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT);
+            }
+        } else {
+            /* At least one orientation is allowed */
+            if (resizable) {
+                if (is_portrait_allowed && is_landscape_allowed) {
+                    /* hint allows both landscape and portrait, promote to full sensor */
+                    req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR;
+                } else {
+                    /* Use the only one allowed "orientation" */
+                    req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+                }
+            } else {
+                /* Fixed window and both orientations are allowed. Choose one. */
+                if (is_portrait_allowed && is_landscape_allowed) {
+                    req = (w > h ? orientation_landscape : orientation_portrait);
+                } else {
+                    /* Use the only one allowed "orientation" */
+                    req = (is_landscape_allowed ? orientation_landscape : orientation_portrait);
+                }
+            }
+        }
+
+        Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint);
+        mSingleton.setRequestedOrientation(req);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void minimizeWindow() {
+
+        if (mSingleton == null) {
+            return;
+        }
+
+        Intent startMain = new Intent(Intent.ACTION_MAIN);
+        startMain.addCategory(Intent.CATEGORY_HOME);
+        startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        mSingleton.startActivity(startMain);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean shouldMinimizeOnFocusLoss() {
+/*
+        if (Build.VERSION.SDK_INT >= 24) {
+            if (mSingleton == null) {
+                return true;
+            }
+
+            if (mSingleton.isInMultiWindowMode()) {
+                return false;
+            }
+
+            if (mSingleton.isInPictureInPictureMode()) {
+                return false;
+            }
+        }
+
+        return true;
+*/
+        return false;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean isScreenKeyboardShown()
+    {
+        if (mTextEdit == null) {
+            return false;
+        }
+
+        if (!mScreenKeyboardShown) {
+            return false;
+        }
+
+        InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+        return imm.isAcceptingText();
+
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean supportsRelativeMouse()
+    {
+        // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under
+        // Android 7 APIs, and simply returns no data under Android 8 APIs.
+        //
+        // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and
+        // thus SDK version 27.  If we are in DeX mode and not API 27 or higher, as a result,
+        // we should stick to relative mode.
+        //
+        if ((Build.VERSION.SDK_INT < 27) && isDeXMode()) {
+            return false;
+        }
+
+        return SDLActivity.getMotionListener().supportsRelativeMouse();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean setRelativeMouseEnabled(boolean enabled)
+    {
+        if (enabled && !supportsRelativeMouse()) {
+            return false;
+        }
+
+        return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean sendMessage(int command, int param) {
+        if (mSingleton == null) {
+            return false;
+        }
+        return mSingleton.sendCommand(command, param);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static Context getContext() {
+        return SDL.getContext();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean isAndroidTV() {
+        UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE);
+        if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) {
+            return true;
+        }
+        if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) {
+            return true;
+        }
+        if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) {
+            return true;
+        }
+        return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV");
+    }
+
+    public static double getDiagonal()
+    {
+        DisplayMetrics metrics = new DisplayMetrics();
+        Activity activity = (Activity)getContext();
+        if (activity == null) {
+            return 0.0;
+        }
+        activity.getWindowManager().getDefaultDisplay().getMetrics(metrics);
+
+        double dWidthInches = metrics.widthPixels / (double)metrics.xdpi;
+        double dHeightInches = metrics.heightPixels / (double)metrics.ydpi;
+
+        return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches));
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean isTablet() {
+        // If our diagonal size is seven inches or greater, we consider ourselves a tablet.
+        return (getDiagonal() >= 7.0);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean isChromebook() {
+        if (getContext() == null) {
+            return false;
+        }
+        return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management");
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean isDeXMode() {
+        if (Build.VERSION.SDK_INT < 24) {
+            return false;
+        }
+        try {
+            final Configuration config = getContext().getResources().getConfiguration();
+            final Class<?> configClass = config.getClass();
+            return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass)
+                    == configClass.getField("semDesktopModeEnabled").getInt(config);
+        } catch(Exception ignored) {
+            return false;
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static DisplayMetrics getDisplayDPI() {
+        return getContext().getResources().getDisplayMetrics();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean getManifestEnvironmentVariables() {
+        try {
+            if (getContext() == null) {
+                return false;
+            }
+
+            ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA);
+            Bundle bundle = applicationInfo.metaData;
+            if (bundle == null) {
+                return false;
+            }
+            String prefix = "SDL_ENV.";
+            final int trimLength = prefix.length();
+            for (String key : bundle.keySet()) {
+                if (key.startsWith(prefix)) {
+                    String name = key.substring(trimLength);
+                    String value = bundle.get(key).toString();
+                    nativeSetenv(name, value);
+                }
+            }
+            /* environment variables set! */
+            return true;
+        } catch (Exception e) {
+           Log.v(TAG, "exception " + e.toString());
+        }
+        return false;
+    }
+
+    // This method is called by SDLControllerManager's API 26 Generic Motion Handler.
+    public static View getContentView() {
+        return mLayout;
+    }
+
+    static class ShowTextInputTask implements Runnable {
+        /*
+         * This is used to regulate the pan&scan method to have some offset from
+         * the bottom edge of the input region and the top edge of an input
+         * method (soft keyboard)
+         */
+        static final int HEIGHT_PADDING = 15;
+
+        public int x, y, w, h;
+
+        public ShowTextInputTask(int x, int y, int w, int h) {
+            this.x = x;
+            this.y = y;
+            this.w = w;
+            this.h = h;
+
+            /* Minimum size of 1 pixel, so it takes focus. */
+            if (this.w <= 0) {
+                this.w = 1;
+            }
+            if (this.h + HEIGHT_PADDING <= 0) {
+                this.h = 1 - HEIGHT_PADDING;
+            }
+        }
+
+        @Override
+        public void run() {
+            RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING);
+            params.leftMargin = x;
+            params.topMargin = y;
+
+            if (mTextEdit == null) {
+                mTextEdit = new DummyEdit(SDL.getContext());
+
+                mLayout.addView(mTextEdit, params);
+            } else {
+                mTextEdit.setLayoutParams(params);
+            }
+
+            mTextEdit.setVisibility(View.VISIBLE);
+            mTextEdit.requestFocus();
+
+            InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE);
+            imm.showSoftInput(mTextEdit, 0);
+
+            mScreenKeyboardShown = true;
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean showTextInput(int x, int y, int w, int h) {
+        // Transfer the task to the main thread as a Runnable
+        return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h));
+    }
+
+    public static boolean isTextInputEvent(KeyEvent event) {
+
+        // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT
+        if (event.isCtrlPressed()) {
+            return false;
+        }
+
+        return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE;
+    }
+
+    public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) {
+        int deviceId = event.getDeviceId();
+        int source = event.getSource();
+
+        if (source == InputDevice.SOURCE_UNKNOWN) {
+            InputDevice device = InputDevice.getDevice(deviceId);
+            if (device != null) {
+                source = device.getSources();
+            }
+        }
+
+//        if (event.getAction() == KeyEvent.ACTION_DOWN) {
+//            Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+//        } else if (event.getAction() == KeyEvent.ACTION_UP) {
+//            Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source);
+//        }
+
+        // Dispatch the different events depending on where they come from
+        // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD
+        // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD
+        //
+        // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and
+        // SOURCE_JOYSTICK, while its key events arrive from the keyboard source
+        // So, retrieve the device itself and check all of its sources
+        if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) {
+            // Note that we process events with specific key codes here
+            if (event.getAction() == KeyEvent.ACTION_DOWN) {
+                if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) {
+                    return true;
+                }
+            } else if (event.getAction() == KeyEvent.ACTION_UP) {
+                if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) {
+                    return true;
+                }
+            }
+        }
+
+        if ((source & InputDevice.SOURCE_KEYBOARD) == InputDevice.SOURCE_KEYBOARD) {
+            if (event.getAction() == KeyEvent.ACTION_DOWN) {
+                if (isTextInputEvent(event)) {
+                    if (ic != null) {
+                        ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1);
+                    } else {
+                        SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1);
+                    }
+                }
+                onNativeKeyDown(keyCode);
+                return true;
+            } else if (event.getAction() == KeyEvent.ACTION_UP) {
+                onNativeKeyUp(keyCode);
+                return true;
+            }
+        }
+
+        if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) {
+            // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses
+            // they are ignored here because sending them as mouse input to SDL is messy
+            if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) {
+                switch (event.getAction()) {
+                case KeyEvent.ACTION_DOWN:
+                case KeyEvent.ACTION_UP:
+                    // mark the event as handled or it will be handled by system
+                    // handling KEYCODE_BACK by system will call onBackPressed()
+                    return true;
+                }
+            }
+        }
+
+        return false;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static Surface getNativeSurface() {
+        if (SDLActivity.mSurface == null) {
+            return null;
+        }
+        return SDLActivity.mSurface.getNativeSurface();
+    }
+
+    // Input
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void initTouch() {
+        int[] ids = InputDevice.getDeviceIds();
+
+        for (int id : ids) {
+            InputDevice device = InputDevice.getDevice(id);
+            /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */
+            if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN
+                    || device.isVirtual())) {
+
+                int touchDevId = device.getId();
+                /*
+                 * Prevent id to be -1, since it's used in SDL internal for synthetic events
+                 * Appears when using Android emulator, eg:
+                 *  adb shell input mouse tap 100 100
+                 *  adb shell input touchscreen tap 100 100
+                 */
+                if (touchDevId < 0) {
+                    touchDevId -= 1;
+                }
+                nativeAddTouch(touchDevId, device.getName());
+            }
+        }
+    }
+
+    // Messagebox
+
+    /** Result of current messagebox. Also used for blocking the calling thread. */
+    protected final int[] messageboxSelection = new int[1];
+
+    /**
+     * This method is called by SDL using JNI.
+     * Shows the messagebox from UI thread and block calling thread.
+     * buttonFlags, buttonIds and buttonTexts must have same length.
+     * @param buttonFlags array containing flags for every button.
+     * @param buttonIds array containing id for every button.
+     * @param buttonTexts array containing text for every button.
+     * @param colors null for default or array of length 5 containing colors.
+     * @return button id or -1.
+     */
+    public int messageboxShowMessageBox(
+            final int flags,
+            final String title,
+            final String message,
+            final int[] buttonFlags,
+            final int[] buttonIds,
+            final String[] buttonTexts,
+            final int[] colors) {
+
+        messageboxSelection[0] = -1;
+
+        // sanity checks
+
+        if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) {
+            return -1; // implementation broken
+        }
+
+        // collect arguments for Dialog
+
+        final Bundle args = new Bundle();
+        args.putInt("flags", flags);
+        args.putString("title", title);
+        args.putString("message", message);
+        args.putIntArray("buttonFlags", buttonFlags);
+        args.putIntArray("buttonIds", buttonIds);
+        args.putStringArray("buttonTexts", buttonTexts);
+        args.putIntArray("colors", colors);
+
+        // trigger Dialog creation on UI thread
+
+        runOnUiThread(new Runnable() {
+            @Override
+            public void run() {
+                messageboxCreateAndShow(args);
+            }
+        });
+
+        // block the calling thread
+
+        synchronized (messageboxSelection) {
+            try {
+                messageboxSelection.wait();
+            } catch (InterruptedException ex) {
+                ex.printStackTrace();
+                return -1;
+            }
+        }
+
+        // return selected value
+
+        return messageboxSelection[0];
+    }
+
+    protected void messageboxCreateAndShow(Bundle args) {
+
+        // TODO set values from "flags" to messagebox dialog
+
+        // get colors
+
+        int[] colors = args.getIntArray("colors");
+        int backgroundColor;
+        int textColor;
+        int buttonBorderColor;
+        int buttonBackgroundColor;
+        int buttonSelectedColor;
+        if (colors != null) {
+            int i = -1;
+            backgroundColor = colors[++i];
+            textColor = colors[++i];
+            buttonBorderColor = colors[++i];
+            buttonBackgroundColor = colors[++i];
+            buttonSelectedColor = colors[++i];
+        } else {
+            backgroundColor = Color.TRANSPARENT;
+            textColor = Color.TRANSPARENT;
+            buttonBorderColor = Color.TRANSPARENT;
+            buttonBackgroundColor = Color.TRANSPARENT;
+            buttonSelectedColor = Color.TRANSPARENT;
+        }
+
+        // create dialog with title and a listener to wake up calling thread
+
+        final AlertDialog dialog = new AlertDialog.Builder(this).create();
+        dialog.setTitle(args.getString("title"));
+        dialog.setCancelable(false);
+        dialog.setOnDismissListener(new DialogInterface.OnDismissListener() {
+            @Override
+            public void onDismiss(DialogInterface unused) {
+                synchronized (messageboxSelection) {
+                    messageboxSelection.notify();
+                }
+            }
+        });
+
+        // create text
+
+        TextView message = new TextView(this);
+        message.setGravity(Gravity.CENTER);
+        message.setText(args.getString("message"));
+        if (textColor != Color.TRANSPARENT) {
+            message.setTextColor(textColor);
+        }
+
+        // create buttons
+
+        int[] buttonFlags = args.getIntArray("buttonFlags");
+        int[] buttonIds = args.getIntArray("buttonIds");
+        String[] buttonTexts = args.getStringArray("buttonTexts");
+
+        final SparseArray<Button> mapping = new SparseArray<Button>();
+
+        LinearLayout buttons = new LinearLayout(this);
+        buttons.setOrientation(LinearLayout.HORIZONTAL);
+        buttons.setGravity(Gravity.CENTER);
+        for (int i = 0; i < buttonTexts.length; ++i) {
+            Button button = new Button(this);
+            final int id = buttonIds[i];
+            button.setOnClickListener(new View.OnClickListener() {
+                @Override
+                public void onClick(View v) {
+                    messageboxSelection[0] = id;
+                    dialog.dismiss();
+                }
+            });
+            if (buttonFlags[i] != 0) {
+                // see SDL_messagebox.h
+                if ((buttonFlags[i] & 0x00000001) != 0) {
+                    mapping.put(KeyEvent.KEYCODE_ENTER, button);
+                }
+                if ((buttonFlags[i] & 0x00000002) != 0) {
+                    mapping.put(KeyEvent.KEYCODE_ESCAPE, button); /* API 11 */
+                }
+            }
+            button.setText(buttonTexts[i]);
+            if (textColor != Color.TRANSPARENT) {
+                button.setTextColor(textColor);
+            }
+            if (buttonBorderColor != Color.TRANSPARENT) {
+                // TODO set color for border of messagebox button
+            }
+            if (buttonBackgroundColor != Color.TRANSPARENT) {
+                Drawable drawable = button.getBackground();
+                if (drawable == null) {
+                    // setting the color this way removes the style
+                    button.setBackgroundColor(buttonBackgroundColor);
+                } else {
+                    // setting the color this way keeps the style (gradient, padding, etc.)
+                    drawable.setColorFilter(buttonBackgroundColor, PorterDuff.Mode.MULTIPLY);
+                }
+            }
+            if (buttonSelectedColor != Color.TRANSPARENT) {
+                // TODO set color for selected messagebox button
+            }
+            buttons.addView(button);
+        }
+
+        // create content
+
+        LinearLayout content = new LinearLayout(this);
+        content.setOrientation(LinearLayout.VERTICAL);
+        content.addView(message);
+        content.addView(buttons);
+        if (backgroundColor != Color.TRANSPARENT) {
+            content.setBackgroundColor(backgroundColor);
+        }
+
+        // add content to dialog and return
+
+        dialog.setView(content);
+        dialog.setOnKeyListener(new Dialog.OnKeyListener() {
+            @Override
+            public boolean onKey(DialogInterface d, int keyCode, KeyEvent event) {
+                Button button = mapping.get(keyCode);
+                if (button != null) {
+                    if (event.getAction() == KeyEvent.ACTION_UP) {
+                        button.performClick();
+                    }
+                    return true; // also for ignored actions
+                }
+                return false;
+            }
+        });
+
+        dialog.show();
+    }
+
+    private final Runnable rehideSystemUi = new Runnable() {
+        @Override
+        public void run() {
+            if (Build.VERSION.SDK_INT >= 19) {
+                int flags = View.SYSTEM_UI_FLAG_FULLSCREEN |
+                        View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
+                        View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY |
+                        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
+                        View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION |
+                        View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE;
+
+                SDLActivity.this.getWindow().getDecorView().setSystemUiVisibility(flags);
+            }
+        }
+    };
+
+    public void onSystemUiVisibilityChange(int visibility) {
+        if (SDLActivity.mFullscreenModeActive && ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0 || (visibility & View.SYSTEM_UI_FLAG_HIDE_NAVIGATION) == 0)) {
+
+            Handler handler = getWindow().getDecorView().getHandler();
+            if (handler != null) {
+                handler.removeCallbacks(rehideSystemUi); // Prevent a hide loop.
+                handler.postDelayed(rehideSystemUi, 2000);
+            }
+
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean clipboardHasText() {
+        return mClipboardHandler.clipboardHasText();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static String clipboardGetText() {
+        return mClipboardHandler.clipboardGetText();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void clipboardSetText(String string) {
+        mClipboardHandler.clipboardSetText(string);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static int createCustomCursor(int[] colors, int width, int height, int hotSpotX, int hotSpotY) {
+        Bitmap bitmap = Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888);
+        ++mLastCursorID;
+
+        if (Build.VERSION.SDK_INT >= 24) {
+            try {
+                mCursors.put(mLastCursorID, PointerIcon.create(bitmap, hotSpotX, hotSpotY));
+            } catch (Exception e) {
+                return 0;
+            }
+        } else {
+            return 0;
+        }
+        return mLastCursorID;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void destroyCustomCursor(int cursorID) {
+        if (Build.VERSION.SDK_INT >= 24) {
+            try {
+                mCursors.remove(cursorID);
+            } catch (Exception e) {
+            }
+        }
+        return;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean setCustomCursor(int cursorID) {
+
+        if (Build.VERSION.SDK_INT >= 24) {
+            try {
+                mSurface.setPointerIcon(mCursors.get(cursorID));
+            } catch (Exception e) {
+                return false;
+            }
+        } else {
+            return false;
+        }
+        return true;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean setSystemCursor(int cursorID) {
+        int cursor_type = 0; //PointerIcon.TYPE_NULL;
+        switch (cursorID) {
+        case SDL_SYSTEM_CURSOR_ARROW:
+            cursor_type = 1000; //PointerIcon.TYPE_ARROW;
+            break;
+        case SDL_SYSTEM_CURSOR_IBEAM:
+            cursor_type = 1008; //PointerIcon.TYPE_TEXT;
+            break;
+        case SDL_SYSTEM_CURSOR_WAIT:
+            cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+            break;
+        case SDL_SYSTEM_CURSOR_CROSSHAIR:
+            cursor_type = 1007; //PointerIcon.TYPE_CROSSHAIR;
+            break;
+        case SDL_SYSTEM_CURSOR_WAITARROW:
+            cursor_type = 1004; //PointerIcon.TYPE_WAIT;
+            break;
+        case SDL_SYSTEM_CURSOR_SIZENWSE:
+            cursor_type = 1017; //PointerIcon.TYPE_TOP_LEFT_DIAGONAL_DOUBLE_ARROW;
+            break;
+        case SDL_SYSTEM_CURSOR_SIZENESW:
+            cursor_type = 1016; //PointerIcon.TYPE_TOP_RIGHT_DIAGONAL_DOUBLE_ARROW;
+            break;
+        case SDL_SYSTEM_CURSOR_SIZEWE:
+            cursor_type = 1014; //PointerIcon.TYPE_HORIZONTAL_DOUBLE_ARROW;
+            break;
+        case SDL_SYSTEM_CURSOR_SIZENS:
+            cursor_type = 1015; //PointerIcon.TYPE_VERTICAL_DOUBLE_ARROW;
+            break;
+        case SDL_SYSTEM_CURSOR_SIZEALL:
+            cursor_type = 1020; //PointerIcon.TYPE_GRAB;
+            break;
+        case SDL_SYSTEM_CURSOR_NO:
+            cursor_type = 1012; //PointerIcon.TYPE_NO_DROP;
+            break;
+        case SDL_SYSTEM_CURSOR_HAND:
+            cursor_type = 1002; //PointerIcon.TYPE_HAND;
+            break;
+        }
+        if (Build.VERSION.SDK_INT >= 24) {
+            try {
+                mSurface.setPointerIcon(PointerIcon.getSystemIcon(SDL.getContext(), cursor_type));
+            } catch (Exception e) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void requestPermission(String permission, int requestCode) {
+        if (Build.VERSION.SDK_INT < 23) {
+            nativePermissionResult(requestCode, true);
+            return;
+        }
+
+        Activity activity = (Activity)getContext();
+        if (activity.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) {
+            activity.requestPermissions(new String[]{permission}, requestCode);
+        } else {
+            nativePermissionResult(requestCode, true);
+        }
+    }
+
+    @Override
+    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
+        boolean result = (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED);
+        nativePermissionResult(requestCode, result);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static int openURL(String url)
+    {
+        try {
+            Intent i = new Intent(Intent.ACTION_VIEW);
+            i.setData(Uri.parse(url));
+
+            int flags = Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_MULTIPLE_TASK;
+            if (Build.VERSION.SDK_INT >= 21) {
+                flags |= Intent.FLAG_ACTIVITY_NEW_DOCUMENT;
+            } else {
+                flags |= Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET;
+            }
+            i.addFlags(flags);
+
+            mSingleton.startActivity(i);
+        } catch (Exception ex) {
+            return -1;
+        }
+        return 0;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static int showToast(String message, int duration, int gravity, int xOffset, int yOffset)
+    {
+        if(null == mSingleton) {
+            return - 1;
+        }
+
+        try
+        {
+            class OneShotTask implements Runnable {
+                String mMessage;
+                int mDuration;
+                int mGravity;
+                int mXOffset;
+                int mYOffset;
+
+                OneShotTask(String message, int duration, int gravity, int xOffset, int yOffset) {
+                    mMessage  = message;
+                    mDuration = duration;
+                    mGravity  = gravity;
+                    mXOffset  = xOffset;
+                    mYOffset  = yOffset;
+                }
+
+                public void run() {
+                    try
+                    {
+                        Toast toast = Toast.makeText(mSingleton, mMessage, mDuration);
+                        if (mGravity >= 0) {
+                            toast.setGravity(mGravity, mXOffset, mYOffset);
+                        }
+                        toast.show();
+                    } catch(Exception ex) {
+                        Log.e(TAG, ex.getMessage());
+                    }
+                }
+            }
+            mSingleton.runOnUiThread(new OneShotTask(message, duration, gravity, xOffset, yOffset));
+        } catch(Exception ex) {
+            return -1;
+        }
+        return 0;
+    }
+}
+
+/**
+    Simple runnable to start the SDL application
+*/
+class SDLMain implements Runnable {
+    @Override
+    public void run() {
+        // Runs SDL_main()
+        String library = SDLActivity.mSingleton.getMainSharedObject();
+        String function = SDLActivity.mSingleton.getMainFunction();
+        String[] arguments = SDLActivity.mSingleton.getArguments();
+
+        try {
+            android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_DISPLAY);
+        } catch (Exception e) {
+            Log.v("SDL", "modify thread properties failed " + e.toString());
+        }
+
+        Log.v("SDL", "Running main function " + function + " from library " + library);
+
+        SDLActivity.nativeRunMain(library, function, arguments);
+
+        Log.v("SDL", "Finished main function");
+
+        if (SDLActivity.mSingleton != null && !SDLActivity.mSingleton.isFinishing()) {
+            // Let's finish the Activity
+            SDLActivity.mSDLThread = null;
+            SDLActivity.mSingleton.finish();
+        }  // else: Activity is already being destroyed
+
+    }
+}
+
+class SDLClipboardHandler implements
+    ClipboardManager.OnPrimaryClipChangedListener {
+
+    protected ClipboardManager mClipMgr;
+
+    SDLClipboardHandler() {
+       mClipMgr = (ClipboardManager) SDL.getContext().getSystemService(Context.CLIPBOARD_SERVICE);
+       mClipMgr.addPrimaryClipChangedListener(this);
+    }
+
+    public boolean clipboardHasText() {
+       return mClipMgr.hasPrimaryClip();
+    }
+
+    public String clipboardGetText() {
+        ClipData clip = mClipMgr.getPrimaryClip();
+        if (clip != null) {
+            ClipData.Item item = clip.getItemAt(0);
+            if (item != null) {
+                CharSequence text = item.getText();
+                if (text != null) {
+                    return text.toString();
+                }
+            }
+        }
+        return null;
+    }
+
+    public void clipboardSetText(String string) {
+       mClipMgr.removePrimaryClipChangedListener(this);
+       ClipData clip = ClipData.newPlainText(null, string);
+       mClipMgr.setPrimaryClip(clip);
+       mClipMgr.addPrimaryClipChangedListener(this);
+    }
+
+    @Override
+    public void onPrimaryClipChanged() {
+        SDLActivity.onNativeClipboardChanged();
+    }
+}
+

+ 394 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDLAudioManager.java

@@ -0,0 +1,394 @@
+package org.libsdl.app;
+
+import android.media.AudioFormat;
+import android.media.AudioManager;
+import android.media.AudioRecord;
+import android.media.AudioTrack;
+import android.media.MediaRecorder;
+import android.os.Build;
+import android.util.Log;
+
+public class SDLAudioManager
+{
+    protected static final String TAG = "SDLAudio";
+
+    protected static AudioTrack mAudioTrack;
+    protected static AudioRecord mAudioRecord;
+
+    public static void initialize() {
+        mAudioTrack = null;
+        mAudioRecord = null;
+    }
+
+    // Audio
+
+    protected static String getAudioFormatString(int audioFormat) {
+        switch (audioFormat) {
+        case AudioFormat.ENCODING_PCM_8BIT:
+            return "8-bit";
+        case AudioFormat.ENCODING_PCM_16BIT:
+            return "16-bit";
+        case AudioFormat.ENCODING_PCM_FLOAT:
+            return "float";
+        default:
+            return Integer.toString(audioFormat);
+        }
+    }
+
+    protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
+        int channelConfig;
+        int sampleSize;
+        int frameSize;
+
+        Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz");
+
+        /* On older devices let's use known good settings */
+        if (Build.VERSION.SDK_INT < 21) {
+            if (desiredChannels > 2) {
+                desiredChannels = 2;
+            }
+        }
+
+        /* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */
+        if (Build.VERSION.SDK_INT < 22) {
+            if (sampleRate < 8000) {
+                sampleRate = 8000;
+            } else if (sampleRate > 48000) {
+                sampleRate = 48000;
+            }
+        }
+
+        if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) {
+            int minSDKVersion = (isCapture ? 23 : 21);
+            if (Build.VERSION.SDK_INT < minSDKVersion) {
+                audioFormat = AudioFormat.ENCODING_PCM_16BIT;
+            }
+        }
+        switch (audioFormat)
+        {
+        case AudioFormat.ENCODING_PCM_8BIT:
+            sampleSize = 1;
+            break;
+        case AudioFormat.ENCODING_PCM_16BIT:
+            sampleSize = 2;
+            break;
+        case AudioFormat.ENCODING_PCM_FLOAT:
+            sampleSize = 4;
+            break;
+        default:
+            Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT");
+            audioFormat = AudioFormat.ENCODING_PCM_16BIT;
+            sampleSize = 2;
+            break;
+        }
+
+        if (isCapture) {
+            switch (desiredChannels) {
+            case 1:
+                channelConfig = AudioFormat.CHANNEL_IN_MONO;
+                break;
+            case 2:
+                channelConfig = AudioFormat.CHANNEL_IN_STEREO;
+                break;
+            default:
+                Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
+                desiredChannels = 2;
+                channelConfig = AudioFormat.CHANNEL_IN_STEREO;
+                break;
+            }
+        } else {
+            switch (desiredChannels) {
+            case 1:
+                channelConfig = AudioFormat.CHANNEL_OUT_MONO;
+                break;
+            case 2:
+                channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+                break;
+            case 3:
+                channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
+                break;
+            case 4:
+                channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
+                break;
+            case 5:
+                channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
+                break;
+            case 6:
+                channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+                break;
+            case 7:
+                channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER;
+                break;
+            case 8:
+                if (Build.VERSION.SDK_INT >= 23) {
+                    channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
+                } else {
+                    Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround");
+                    desiredChannels = 6;
+                    channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
+                }
+                break;
+            default:
+                Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
+                desiredChannels = 2;
+                channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
+                break;
+            }
+
+/*
+            Log.v(TAG, "Speaker configuration (and order of channels):");
+
+            if ((channelConfig & 0x00000004) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_FRONT_LEFT");
+            }
+            if ((channelConfig & 0x00000008) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_FRONT_RIGHT");
+            }
+            if ((channelConfig & 0x00000010) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_FRONT_CENTER");
+            }
+            if ((channelConfig & 0x00000020) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_LOW_FREQUENCY");
+            }
+            if ((channelConfig & 0x00000040) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_BACK_LEFT");
+            }
+            if ((channelConfig & 0x00000080) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_BACK_RIGHT");
+            }
+            if ((channelConfig & 0x00000100) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_FRONT_LEFT_OF_CENTER");
+            }
+            if ((channelConfig & 0x00000200) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_FRONT_RIGHT_OF_CENTER");
+            }
+            if ((channelConfig & 0x00000400) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_BACK_CENTER");
+            }
+            if ((channelConfig & 0x00000800) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_SIDE_LEFT");
+            }
+            if ((channelConfig & 0x00001000) != 0) {
+                Log.v(TAG, "   CHANNEL_OUT_SIDE_RIGHT");
+            }
+*/
+        }
+        frameSize = (sampleSize * desiredChannels);
+
+        // Let the user pick a larger buffer if they really want -- but ye
+        // gods they probably shouldn't, the minimums are horrifyingly high
+        // latency already
+        int minBufferSize;
+        if (isCapture) {
+            minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
+        } else {
+            minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
+        }
+        desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize);
+
+        int[] results = new int[4];
+
+        if (isCapture) {
+            if (mAudioRecord == null) {
+                mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate,
+                        channelConfig, audioFormat, desiredFrames * frameSize);
+
+                // see notes about AudioTrack state in audioOpen(), above. Probably also applies here.
+                if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
+                    Log.e(TAG, "Failed during initialization of AudioRecord");
+                    mAudioRecord.release();
+                    mAudioRecord = null;
+                    return null;
+                }
+
+                mAudioRecord.startRecording();
+            }
+
+            results[0] = mAudioRecord.getSampleRate();
+            results[1] = mAudioRecord.getAudioFormat();
+            results[2] = mAudioRecord.getChannelCount();
+
+        } else {
+            if (mAudioTrack == null) {
+                mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM);
+
+                // Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid
+                // Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java
+                // Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState()
+                if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
+                    /* Try again, with safer values */
+
+                    Log.e(TAG, "Failed during initialization of Audio Track");
+                    mAudioTrack.release();
+                    mAudioTrack = null;
+                    return null;
+                }
+
+                mAudioTrack.play();
+            }
+
+            results[0] = mAudioTrack.getSampleRate();
+            results[1] = mAudioTrack.getAudioFormat();
+            results[2] = mAudioTrack.getChannelCount();
+        }
+        results[3] = desiredFrames;
+
+        Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz");
+
+        return results;
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
+        return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void audioWriteFloatBuffer(float[] buffer) {
+        if (mAudioTrack == null) {
+            Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+            return;
+        }
+
+        for (int i = 0; i < buffer.length;) {
+            int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING);
+            if (result > 0) {
+                i += result;
+            } else if (result == 0) {
+                try {
+                    Thread.sleep(1);
+                } catch(InterruptedException e) {
+                    // Nom nom
+                }
+            } else {
+                Log.w(TAG, "SDL audio: error return from write(float)");
+                return;
+            }
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void audioWriteShortBuffer(short[] buffer) {
+        if (mAudioTrack == null) {
+            Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+            return;
+        }
+
+        for (int i = 0; i < buffer.length;) {
+            int result = mAudioTrack.write(buffer, i, buffer.length - i);
+            if (result > 0) {
+                i += result;
+            } else if (result == 0) {
+                try {
+                    Thread.sleep(1);
+                } catch(InterruptedException e) {
+                    // Nom nom
+                }
+            } else {
+                Log.w(TAG, "SDL audio: error return from write(short)");
+                return;
+            }
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void audioWriteByteBuffer(byte[] buffer) {
+        if (mAudioTrack == null) {
+            Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
+            return;
+        }
+
+        for (int i = 0; i < buffer.length; ) {
+            int result = mAudioTrack.write(buffer, i, buffer.length - i);
+            if (result > 0) {
+                i += result;
+            } else if (result == 0) {
+                try {
+                    Thread.sleep(1);
+                } catch(InterruptedException e) {
+                    // Nom nom
+                }
+            } else {
+                Log.w(TAG, "SDL audio: error return from write(byte)");
+                return;
+            }
+        }
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames) {
+        return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames);
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static int captureReadFloatBuffer(float[] buffer, boolean blocking) {
+        return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static int captureReadShortBuffer(short[] buffer, boolean blocking) {
+        if (Build.VERSION.SDK_INT < 23) {
+            return mAudioRecord.read(buffer, 0, buffer.length);
+        } else {
+            return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+        }
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static int captureReadByteBuffer(byte[] buffer, boolean blocking) {
+        if (Build.VERSION.SDK_INT < 23) {
+            return mAudioRecord.read(buffer, 0, buffer.length);
+        } else {
+            return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
+        }
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static void audioClose() {
+        if (mAudioTrack != null) {
+            mAudioTrack.stop();
+            mAudioTrack.release();
+            mAudioTrack = null;
+        }
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static void captureClose() {
+        if (mAudioRecord != null) {
+            mAudioRecord.stop();
+            mAudioRecord.release();
+            mAudioRecord = null;
+        }
+    }
+
+    /** This method is called by SDL using JNI. */
+    public static void audioSetThreadPriority(boolean iscapture, int device_id) {
+        try {
+
+            /* Set thread name */
+            if (iscapture) {
+                Thread.currentThread().setName("SDLAudioC" + device_id);
+            } else {
+                Thread.currentThread().setName("SDLAudioP" + device_id);
+            }
+
+            /* Set thread priority */
+            android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
+
+        } catch (Exception e) {
+            Log.v(TAG, "modify thread properties failed " + e.toString());
+        }
+    }
+
+    public static native int nativeSetupJNI();
+}

+ 788 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDLControllerManager.java

@@ -0,0 +1,788 @@
+package org.libsdl.app;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+
+import android.content.Context;
+import android.os.Build;
+import android.os.VibrationEffect;
+import android.os.Vibrator;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.View;
+
+
+public class SDLControllerManager
+{
+
+    public static native int nativeSetupJNI();
+
+    public static native int nativeAddJoystick(int device_id, String name, String desc,
+                                               int vendor_id, int product_id,
+                                               boolean is_accelerometer, int button_mask,
+                                               int naxes, int nhats, int nballs);
+    public static native int nativeRemoveJoystick(int device_id);
+    public static native int nativeAddHaptic(int device_id, String name);
+    public static native int nativeRemoveHaptic(int device_id);
+    public static native int onNativePadDown(int device_id, int keycode);
+    public static native int onNativePadUp(int device_id, int keycode);
+    public static native void onNativeJoy(int device_id, int axis,
+                                          float value);
+    public static native void onNativeHat(int device_id, int hat_id,
+                                          int x, int y);
+
+    protected static SDLJoystickHandler mJoystickHandler;
+    protected static SDLHapticHandler mHapticHandler;
+
+    private static final String TAG = "SDLControllerManager";
+
+    public static void initialize() {
+        if (mJoystickHandler == null) {
+            if (Build.VERSION.SDK_INT >= 19) {
+                mJoystickHandler = new SDLJoystickHandler_API19();
+            } else {
+                mJoystickHandler = new SDLJoystickHandler_API16();
+            }
+        }
+
+        if (mHapticHandler == null) {
+            if (Build.VERSION.SDK_INT >= 26) {
+                mHapticHandler = new SDLHapticHandler_API26();
+            } else {
+                mHapticHandler = new SDLHapticHandler();
+            }
+        }
+    }
+
+    // Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
+    public static boolean handleJoystickMotionEvent(MotionEvent event) {
+        return mJoystickHandler.handleMotionEvent(event);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void pollInputDevices() {
+        mJoystickHandler.pollInputDevices();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void pollHapticDevices() {
+        mHapticHandler.pollHapticDevices();
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void hapticRun(int device_id, float intensity, int length) {
+        mHapticHandler.run(device_id, intensity, length);
+    }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static void hapticStop(int device_id)
+    {
+        mHapticHandler.stop(device_id);
+    }
+
+    // Check if a given device is considered a possible SDL joystick
+    public static boolean isDeviceSDLJoystick(int deviceId) {
+        InputDevice device = InputDevice.getDevice(deviceId);
+        // We cannot use InputDevice.isVirtual before API 16, so let's accept
+        // only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
+        if ((device == null) || (deviceId < 0)) {
+            return false;
+        }
+        int sources = device.getSources();
+
+        /* This is called for every button press, so let's not spam the logs */
+        /*
+        if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+            Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
+        }
+        if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
+            Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
+        }
+        if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
+            Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
+        }
+        */
+
+        return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
+                ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
+                ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
+        );
+    }
+
+}
+
+class SDLJoystickHandler {
+
+    /**
+     * Handles given MotionEvent.
+     * @param event the event to be handled.
+     * @return if given event was processed.
+     */
+    public boolean handleMotionEvent(MotionEvent event) {
+        return false;
+    }
+
+    /**
+     * Handles adding and removing of input devices.
+     */
+    public void pollInputDevices() {
+    }
+}
+
+/* Actual joystick functionality available for API >= 12 devices */
+class SDLJoystickHandler_API16 extends SDLJoystickHandler {
+
+    static class SDLJoystick {
+        public int device_id;
+        public String name;
+        public String desc;
+        public ArrayList<InputDevice.MotionRange> axes;
+        public ArrayList<InputDevice.MotionRange> hats;
+    }
+    static class RangeComparator implements Comparator<InputDevice.MotionRange> {
+        @Override
+        public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
+            // Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
+            int arg0Axis = arg0.getAxis();
+            int arg1Axis = arg1.getAxis();
+            if (arg0Axis == MotionEvent.AXIS_GAS) {
+                arg0Axis = MotionEvent.AXIS_BRAKE;
+            } else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
+                arg0Axis = MotionEvent.AXIS_GAS;
+            }
+            if (arg1Axis == MotionEvent.AXIS_GAS) {
+                arg1Axis = MotionEvent.AXIS_BRAKE;
+            } else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
+                arg1Axis = MotionEvent.AXIS_GAS;
+            }
+
+            return arg0Axis - arg1Axis;
+        }
+    }
+
+    private final ArrayList<SDLJoystick> mJoysticks;
+
+    public SDLJoystickHandler_API16() {
+
+        mJoysticks = new ArrayList<SDLJoystick>();
+    }
+
+    @Override
+    public void pollInputDevices() {
+        int[] deviceIds = InputDevice.getDeviceIds();
+
+        for (int device_id : deviceIds) {
+            if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
+                SDLJoystick joystick = getJoystick(device_id);
+                if (joystick == null) {
+                    InputDevice joystickDevice = InputDevice.getDevice(device_id);
+                    joystick = new SDLJoystick();
+                    joystick.device_id = device_id;
+                    joystick.name = joystickDevice.getName();
+                    joystick.desc = getJoystickDescriptor(joystickDevice);
+                    joystick.axes = new ArrayList<InputDevice.MotionRange>();
+                    joystick.hats = new ArrayList<InputDevice.MotionRange>();
+
+                    List<InputDevice.MotionRange> ranges = joystickDevice.getMotionRanges();
+                    Collections.sort(ranges, new RangeComparator());
+                    for (InputDevice.MotionRange range : ranges) {
+                        if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
+                            if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
+                                joystick.hats.add(range);
+                            } else {
+                                joystick.axes.add(range);
+                            }
+                        }
+                    }
+
+                    mJoysticks.add(joystick);
+                    SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
+                            getVendorId(joystickDevice), getProductId(joystickDevice), false,
+                            getButtonMask(joystickDevice), joystick.axes.size(), joystick.hats.size()/2, 0);
+                }
+            }
+        }
+
+        /* Check removed devices */
+        ArrayList<Integer> removedDevices = null;
+        for (SDLJoystick joystick : mJoysticks) {
+            int device_id = joystick.device_id;
+            int i;
+            for (i = 0; i < deviceIds.length; i++) {
+                if (device_id == deviceIds[i]) break;
+            }
+            if (i == deviceIds.length) {
+                if (removedDevices == null) {
+                    removedDevices = new ArrayList<Integer>();
+                }
+                removedDevices.add(device_id);
+            }
+        }
+
+        if (removedDevices != null) {
+            for (int device_id : removedDevices) {
+                SDLControllerManager.nativeRemoveJoystick(device_id);
+                for (int i = 0; i < mJoysticks.size(); i++) {
+                    if (mJoysticks.get(i).device_id == device_id) {
+                        mJoysticks.remove(i);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    protected SDLJoystick getJoystick(int device_id) {
+        for (SDLJoystick joystick : mJoysticks) {
+            if (joystick.device_id == device_id) {
+                return joystick;
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public boolean handleMotionEvent(MotionEvent event) {
+        int actionPointerIndex = event.getActionIndex();
+        int action = event.getActionMasked();
+        if (action == MotionEvent.ACTION_MOVE) {
+            SDLJoystick joystick = getJoystick(event.getDeviceId());
+            if (joystick != null) {
+                for (int i = 0; i < joystick.axes.size(); i++) {
+                    InputDevice.MotionRange range = joystick.axes.get(i);
+                    /* Normalize the value to -1...1 */
+                    float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
+                    SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
+                }
+                for (int i = 0; i < joystick.hats.size() / 2; i++) {
+                    int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
+                    int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
+                    SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
+                }
+            }
+        }
+        return true;
+    }
+
+    public String getJoystickDescriptor(InputDevice joystickDevice) {
+        String desc = joystickDevice.getDescriptor();
+
+        if (desc != null && !desc.isEmpty()) {
+            return desc;
+        }
+
+        return joystickDevice.getName();
+    }
+    public int getProductId(InputDevice joystickDevice) {
+        return 0;
+    }
+    public int getVendorId(InputDevice joystickDevice) {
+        return 0;
+    }
+    public int getButtonMask(InputDevice joystickDevice) {
+        return -1;
+    }
+}
+
+class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
+
+    @Override
+    public int getProductId(InputDevice joystickDevice) {
+        return joystickDevice.getProductId();
+    }
+
+    @Override
+    public int getVendorId(InputDevice joystickDevice) {
+        return joystickDevice.getVendorId();
+    }
+
+    @Override
+    public int getButtonMask(InputDevice joystickDevice) {
+        int button_mask = 0;
+        int[] keys = new int[] {
+            KeyEvent.KEYCODE_BUTTON_A,
+            KeyEvent.KEYCODE_BUTTON_B,
+            KeyEvent.KEYCODE_BUTTON_X,
+            KeyEvent.KEYCODE_BUTTON_Y,
+            KeyEvent.KEYCODE_BACK,
+            KeyEvent.KEYCODE_MENU,
+            KeyEvent.KEYCODE_BUTTON_MODE,
+            KeyEvent.KEYCODE_BUTTON_START,
+            KeyEvent.KEYCODE_BUTTON_THUMBL,
+            KeyEvent.KEYCODE_BUTTON_THUMBR,
+            KeyEvent.KEYCODE_BUTTON_L1,
+            KeyEvent.KEYCODE_BUTTON_R1,
+            KeyEvent.KEYCODE_DPAD_UP,
+            KeyEvent.KEYCODE_DPAD_DOWN,
+            KeyEvent.KEYCODE_DPAD_LEFT,
+            KeyEvent.KEYCODE_DPAD_RIGHT,
+            KeyEvent.KEYCODE_BUTTON_SELECT,
+            KeyEvent.KEYCODE_DPAD_CENTER,
+
+            // These don't map into any SDL controller buttons directly
+            KeyEvent.KEYCODE_BUTTON_L2,
+            KeyEvent.KEYCODE_BUTTON_R2,
+            KeyEvent.KEYCODE_BUTTON_C,
+            KeyEvent.KEYCODE_BUTTON_Z,
+            KeyEvent.KEYCODE_BUTTON_1,
+            KeyEvent.KEYCODE_BUTTON_2,
+            KeyEvent.KEYCODE_BUTTON_3,
+            KeyEvent.KEYCODE_BUTTON_4,
+            KeyEvent.KEYCODE_BUTTON_5,
+            KeyEvent.KEYCODE_BUTTON_6,
+            KeyEvent.KEYCODE_BUTTON_7,
+            KeyEvent.KEYCODE_BUTTON_8,
+            KeyEvent.KEYCODE_BUTTON_9,
+            KeyEvent.KEYCODE_BUTTON_10,
+            KeyEvent.KEYCODE_BUTTON_11,
+            KeyEvent.KEYCODE_BUTTON_12,
+            KeyEvent.KEYCODE_BUTTON_13,
+            KeyEvent.KEYCODE_BUTTON_14,
+            KeyEvent.KEYCODE_BUTTON_15,
+            KeyEvent.KEYCODE_BUTTON_16,
+        };
+        int[] masks = new int[] {
+            (1 << 0),   // A -> A
+            (1 << 1),   // B -> B
+            (1 << 2),   // X -> X
+            (1 << 3),   // Y -> Y
+            (1 << 4),   // BACK -> BACK
+            (1 << 6),   // MENU -> START
+            (1 << 5),   // MODE -> GUIDE
+            (1 << 6),   // START -> START
+            (1 << 7),   // THUMBL -> LEFTSTICK
+            (1 << 8),   // THUMBR -> RIGHTSTICK
+            (1 << 9),   // L1 -> LEFTSHOULDER
+            (1 << 10),  // R1 -> RIGHTSHOULDER
+            (1 << 11),  // DPAD_UP -> DPAD_UP
+            (1 << 12),  // DPAD_DOWN -> DPAD_DOWN
+            (1 << 13),  // DPAD_LEFT -> DPAD_LEFT
+            (1 << 14),  // DPAD_RIGHT -> DPAD_RIGHT
+            (1 << 4),   // SELECT -> BACK
+            (1 << 0),   // DPAD_CENTER -> A
+            (1 << 15),  // L2 -> ??
+            (1 << 16),  // R2 -> ??
+            (1 << 17),  // C -> ??
+            (1 << 18),  // Z -> ??
+            (1 << 20),  // 1 -> ??
+            (1 << 21),  // 2 -> ??
+            (1 << 22),  // 3 -> ??
+            (1 << 23),  // 4 -> ??
+            (1 << 24),  // 5 -> ??
+            (1 << 25),  // 6 -> ??
+            (1 << 26),  // 7 -> ??
+            (1 << 27),  // 8 -> ??
+            (1 << 28),  // 9 -> ??
+            (1 << 29),  // 10 -> ??
+            (1 << 30),  // 11 -> ??
+            (1 << 31),  // 12 -> ??
+            // We're out of room...
+            0xFFFFFFFF,  // 13 -> ??
+            0xFFFFFFFF,  // 14 -> ??
+            0xFFFFFFFF,  // 15 -> ??
+            0xFFFFFFFF,  // 16 -> ??
+        };
+        boolean[] has_keys = joystickDevice.hasKeys(keys);
+        for (int i = 0; i < keys.length; ++i) {
+            if (has_keys[i]) {
+                button_mask |= masks[i];
+            }
+        }
+        return button_mask;
+    }
+}
+
+class SDLHapticHandler_API26 extends SDLHapticHandler {
+    @Override
+    public void run(int device_id, float intensity, int length) {
+        SDLHaptic haptic = getHaptic(device_id);
+        if (haptic != null) {
+            Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length);
+            if (intensity == 0.0f) {
+                stop(device_id);
+                return;
+            }
+
+            int vibeValue = Math.round(intensity * 255);
+
+            if (vibeValue > 255) {
+                vibeValue = 255;
+            }
+            if (vibeValue < 1) {
+                stop(device_id);
+                return;
+            }
+            try {
+                haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
+            }
+            catch (Exception e) {
+                // Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
+                // something went horribly wrong with the Android 8.0 APIs.
+                haptic.vib.vibrate(length);
+            }
+        }
+    }
+}
+
+class SDLHapticHandler {
+
+    static class SDLHaptic {
+        public int device_id;
+        public String name;
+        public Vibrator vib;
+    }
+
+    private final ArrayList<SDLHaptic> mHaptics;
+
+    public SDLHapticHandler() {
+        mHaptics = new ArrayList<SDLHaptic>();
+    }
+
+    public void run(int device_id, float intensity, int length) {
+        SDLHaptic haptic = getHaptic(device_id);
+        if (haptic != null) {
+            haptic.vib.vibrate(length);
+        }
+    }
+
+    public void stop(int device_id) {
+        SDLHaptic haptic = getHaptic(device_id);
+        if (haptic != null) {
+            haptic.vib.cancel();
+        }
+    }
+
+    public void pollHapticDevices() {
+
+        final int deviceId_VIBRATOR_SERVICE = 999999;
+        boolean hasVibratorService = false;
+
+        int[] deviceIds = InputDevice.getDeviceIds();
+        // It helps processing the device ids in reverse order
+        // For example, in the case of the XBox 360 wireless dongle,
+        // so the first controller seen by SDL matches what the receiver
+        // considers to be the first controller
+
+        for (int i = deviceIds.length - 1; i > -1; i--) {
+            SDLHaptic haptic = getHaptic(deviceIds[i]);
+            if (haptic == null) {
+                InputDevice device = InputDevice.getDevice(deviceIds[i]);
+                Vibrator vib = device.getVibrator();
+                if (vib.hasVibrator()) {
+                    haptic = new SDLHaptic();
+                    haptic.device_id = deviceIds[i];
+                    haptic.name = device.getName();
+                    haptic.vib = vib;
+                    mHaptics.add(haptic);
+                    SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
+                }
+            }
+        }
+
+        /* Check VIBRATOR_SERVICE */
+        Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
+        if (vib != null) {
+            hasVibratorService = vib.hasVibrator();
+
+            if (hasVibratorService) {
+                SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
+                if (haptic == null) {
+                    haptic = new SDLHaptic();
+                    haptic.device_id = deviceId_VIBRATOR_SERVICE;
+                    haptic.name = "VIBRATOR_SERVICE";
+                    haptic.vib = vib;
+                    mHaptics.add(haptic);
+                    SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
+                }
+            }
+        }
+
+        /* Check removed devices */
+        ArrayList<Integer> removedDevices = null;
+        for (SDLHaptic haptic : mHaptics) {
+            int device_id = haptic.device_id;
+            int i;
+            for (i = 0; i < deviceIds.length; i++) {
+                if (device_id == deviceIds[i]) break;
+            }
+
+            if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
+                if (i == deviceIds.length) {
+                    if (removedDevices == null) {
+                        removedDevices = new ArrayList<Integer>();
+                    }
+                    removedDevices.add(device_id);
+                }
+            }  // else: don't remove the vibrator if it is still present
+        }
+
+        if (removedDevices != null) {
+            for (int device_id : removedDevices) {
+                SDLControllerManager.nativeRemoveHaptic(device_id);
+                for (int i = 0; i < mHaptics.size(); i++) {
+                    if (mHaptics.get(i).device_id == device_id) {
+                        mHaptics.remove(i);
+                        break;
+                    }
+                }
+            }
+        }
+    }
+
+    protected SDLHaptic getHaptic(int device_id) {
+        for (SDLHaptic haptic : mHaptics) {
+            if (haptic.device_id == device_id) {
+                return haptic;
+            }
+        }
+        return null;
+    }
+}
+
+class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener {
+    // Generic Motion (mouse hover, joystick...) events go here
+    @Override
+    public boolean onGenericMotion(View v, MotionEvent event) {
+        float x, y;
+        int action;
+
+        switch ( event.getSource() ) {
+            case InputDevice.SOURCE_JOYSTICK:
+                return SDLControllerManager.handleJoystickMotionEvent(event);
+
+            case InputDevice.SOURCE_MOUSE:
+                action = event.getActionMasked();
+                switch (action) {
+                    case MotionEvent.ACTION_SCROLL:
+                        x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+                        y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+                        SDLActivity.onNativeMouse(0, action, x, y, false);
+                        return true;
+
+                    case MotionEvent.ACTION_HOVER_MOVE:
+                        x = event.getX(0);
+                        y = event.getY(0);
+
+                        SDLActivity.onNativeMouse(0, action, x, y, false);
+                        return true;
+
+                    default:
+                        break;
+                }
+                break;
+
+            default:
+                break;
+        }
+
+        // Event was not managed
+        return false;
+    }
+
+    public boolean supportsRelativeMouse() {
+        return false;
+    }
+
+    public boolean inRelativeMode() {
+        return false;
+    }
+
+    public boolean setRelativeMouseEnabled(boolean enabled) {
+        return false;
+    }
+
+    public void reclaimRelativeMouseModeIfNeeded()
+    {
+
+    }
+
+    public float getEventX(MotionEvent event) {
+        return event.getX(0);
+    }
+
+    public float getEventY(MotionEvent event) {
+        return event.getY(0);
+    }
+
+}
+
+class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 {
+    // Generic Motion (mouse hover, joystick...) events go here
+
+    private boolean mRelativeModeEnabled;
+
+    @Override
+    public boolean onGenericMotion(View v, MotionEvent event) {
+
+        // Handle relative mouse mode
+        if (mRelativeModeEnabled) {
+            if (event.getSource() == InputDevice.SOURCE_MOUSE) {
+                int action = event.getActionMasked();
+                if (action == MotionEvent.ACTION_HOVER_MOVE) {
+                    float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
+                    float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
+                    SDLActivity.onNativeMouse(0, action, x, y, true);
+                    return true;
+                }
+            }
+        }
+
+        // Event was not managed, call SDLGenericMotionListener_API12 method
+        return super.onGenericMotion(v, event);
+    }
+
+    @Override
+    public boolean supportsRelativeMouse() {
+        return true;
+    }
+
+    @Override
+    public boolean inRelativeMode() {
+        return mRelativeModeEnabled;
+    }
+
+    @Override
+    public boolean setRelativeMouseEnabled(boolean enabled) {
+        mRelativeModeEnabled = enabled;
+        return true;
+    }
+
+    @Override
+    public float getEventX(MotionEvent event) {
+        if (mRelativeModeEnabled) {
+            return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
+        } else {
+            return event.getX(0);
+        }
+    }
+
+    @Override
+    public float getEventY(MotionEvent event) {
+        if (mRelativeModeEnabled) {
+            return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
+        } else {
+            return event.getY(0);
+        }
+    }
+}
+
+class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
+    // Generic Motion (mouse hover, joystick...) events go here
+    private boolean mRelativeModeEnabled;
+
+    @Override
+    public boolean onGenericMotion(View v, MotionEvent event) {
+        float x, y;
+        int action;
+
+        switch ( event.getSource() ) {
+            case InputDevice.SOURCE_JOYSTICK:
+                return SDLControllerManager.handleJoystickMotionEvent(event);
+
+            case InputDevice.SOURCE_MOUSE:
+            // DeX desktop mouse cursor is a separate non-standard input type.
+            case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN:
+                action = event.getActionMasked();
+                switch (action) {
+                    case MotionEvent.ACTION_SCROLL:
+                        x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+                        y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+                        SDLActivity.onNativeMouse(0, action, x, y, false);
+                        return true;
+
+                    case MotionEvent.ACTION_HOVER_MOVE:
+                        x = event.getX(0);
+                        y = event.getY(0);
+                        SDLActivity.onNativeMouse(0, action, x, y, false);
+                        return true;
+
+                    default:
+                        break;
+                }
+                break;
+
+            case InputDevice.SOURCE_MOUSE_RELATIVE:
+                action = event.getActionMasked();
+                switch (action) {
+                    case MotionEvent.ACTION_SCROLL:
+                        x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+                        y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+                        SDLActivity.onNativeMouse(0, action, x, y, false);
+                        return true;
+
+                    case MotionEvent.ACTION_HOVER_MOVE:
+                        x = event.getX(0);
+                        y = event.getY(0);
+                        SDLActivity.onNativeMouse(0, action, x, y, true);
+                        return true;
+
+                    default:
+                        break;
+                }
+                break;
+
+            default:
+                break;
+        }
+
+        // Event was not managed
+        return false;
+    }
+
+    @Override
+    public boolean supportsRelativeMouse() {
+        return (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27));
+    }
+
+    @Override
+    public boolean inRelativeMode() {
+        return mRelativeModeEnabled;
+    }
+
+    @Override
+    public boolean setRelativeMouseEnabled(boolean enabled) {
+        if (!SDLActivity.isDeXMode() || (Build.VERSION.SDK_INT >= 27)) {
+            if (enabled) {
+                SDLActivity.getContentView().requestPointerCapture();
+            } else {
+                SDLActivity.getContentView().releasePointerCapture();
+            }
+            mRelativeModeEnabled = enabled;
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    @Override
+    public void reclaimRelativeMouseModeIfNeeded()
+    {
+        if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
+            SDLActivity.getContentView().requestPointerCapture();
+        }
+    }
+
+    @Override
+    public float getEventX(MotionEvent event) {
+        // Relative mouse in capture mode will only have relative for X/Y
+        return event.getX(0);
+    }
+
+    @Override
+    public float getEventY(MotionEvent event) {
+        // Relative mouse in capture mode will only have relative for X/Y
+        return event.getY(0);
+    }
+}

+ 144 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDLInputConnection.java

@@ -0,0 +1,144 @@
+package org.libsdl.app;
+
+import android.text.Editable;
+import android.view.KeyEvent;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.widget.EditText;
+
+public class SDLInputConnection extends BaseInputConnection
+{
+
+    protected EditText mEditText;
+    protected String mCommittedText = "";
+
+    public SDLInputConnection(View targetView, boolean fullEditor)
+    {
+        super(targetView, fullEditor);
+        mEditText = new EditText(SDL.getContext());
+    }
+
+    @Override
+    public Editable getEditable()
+    {
+        return mEditText.getEditableText();
+    }
+
+    @Override
+    public boolean sendKeyEvent(KeyEvent event)
+    {
+        /*
+         * This used to handle the keycodes from soft keyboard (and IME-translated input from hardkeyboard)
+         * However, as of Ice Cream Sandwich and later, almost all soft keyboard doesn't generate key presses
+         * and so we need to generate them ourselves in commitText.  To avoid duplicates on the handful of keys
+         * that still do, we empty this out.
+         */
+
+        /*
+         * Return DOES still generate a key event, however.  So rather than using it as the 'click a button' key
+         * as we do with physical keyboards, let's just use it to hide the keyboard.
+         */
+
+        if (event.getKeyCode() == KeyEvent.KEYCODE_ENTER)
+        {
+            if (SDLActivity.onNativeSoftReturnKey())
+            {
+                return true;
+            }
+        }
+
+        return super.sendKeyEvent(event);
+    }
+
+    @Override
+    public boolean commitText(CharSequence text, int newCursorPosition)
+    {
+        if (!super.commitText(text, newCursorPosition))
+        {
+            return false;
+        }
+        updateText();
+        return true;
+    }
+
+    @Override
+    public boolean setComposingText(CharSequence text, int newCursorPosition)
+    {
+        if (!super.setComposingText(text, newCursorPosition))
+        {
+            return false;
+        }
+        updateText();
+        return true;
+    }
+
+    @Override
+    public boolean deleteSurroundingText(int beforeLength, int afterLength)
+    {
+        if (!super.deleteSurroundingText(beforeLength, afterLength))
+        {
+            return false;
+        }
+        updateText();
+        return true;
+    }
+
+    protected void updateText()
+    {
+        final Editable content = getEditable();
+        if (content == null)
+        {
+            return;
+        }
+
+        String text = content.toString();
+        int compareLength = Math.min(text.length(), mCommittedText.length());
+        int matchLength, offset;
+
+        /* Backspace over characters that are no longer in the string */
+        for (matchLength = 0; matchLength < compareLength; )
+        {
+            int codePoint = mCommittedText.codePointAt(matchLength);
+            if (codePoint != text.codePointAt(matchLength))
+            {
+                break;
+            }
+            matchLength += Character.charCount(codePoint);
+        }
+        /* FIXME: This doesn't handle graphemes, like '🌬️' */
+        for (offset = matchLength; offset < mCommittedText.length(); )
+        {
+            int codePoint = mCommittedText.codePointAt(offset);
+            nativeGenerateScancodeForUnichar('\b');
+            offset += Character.charCount(codePoint);
+        }
+
+        if (matchLength < text.length())
+        {
+            String pendingText = text.subSequence(matchLength, text.length()).toString();
+            for (offset = 0; offset < pendingText.length(); )
+            {
+                int codePoint = pendingText.codePointAt(offset);
+                if (codePoint == '\n')
+                {
+                    if (SDLActivity.onNativeSoftReturnKey())
+                    {
+                        return;
+                    }
+                }
+                /* Higher code points don't generate simulated scancodes */
+                if (codePoint < 128)
+                {
+                    nativeGenerateScancodeForUnichar((char) codePoint);
+                }
+                offset += Character.charCount(codePoint);
+            }
+            SDLInputConnection.nativeCommitText(pendingText, 0);
+        }
+        mCommittedText = text;
+    }
+
+    public static native void nativeCommitText(String text, int newCursorPosition);
+
+    public static native void nativeGenerateScancodeForUnichar(char c);
+}

+ 405 - 0
android/vcmi-app/src/main/java/org/libsdl/app/SDLSurface.java

@@ -0,0 +1,405 @@
+package org.libsdl.app;
+
+
+import android.content.Context;
+import android.content.pm.ActivityInfo;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+import android.os.Build;
+import android.util.DisplayMetrics;
+import android.util.Log;
+import android.view.Display;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.MotionEvent;
+import android.view.Surface;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+import android.view.View;
+import android.view.WindowManager;
+
+
+/**
+    SDLSurface. This is what we draw on, so we need to know when it's created
+    in order to do anything useful.
+
+    Because of this, that's where we set up the SDL thread
+*/
+public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
+    View.OnKeyListener, View.OnTouchListener, SensorEventListener  {
+
+    // Sensors
+    protected SensorManager mSensorManager;
+    protected Display mDisplay;
+
+    // Keep track of the surface size to normalize touch events
+    protected float mWidth, mHeight;
+
+    // Is SurfaceView ready for rendering
+    public boolean mIsSurfaceReady;
+
+    // Startup
+    public SDLSurface(Context context) {
+        super(context);
+        getHolder().addCallback(this);
+
+        setFocusable(true);
+        setFocusableInTouchMode(true);
+        requestFocus();
+        setOnKeyListener(this);
+        setOnTouchListener(this);
+
+        mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
+        mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
+
+        setOnGenericMotionListener(SDLActivity.getMotionListener());
+
+        // Some arbitrary defaults to avoid a potential division by zero
+        mWidth = 1.0f;
+        mHeight = 1.0f;
+
+        mIsSurfaceReady = false;
+    }
+
+    public void handlePause() {
+        enableSensor(Sensor.TYPE_ACCELEROMETER, false);
+    }
+
+    public void handleResume() {
+        setFocusable(true);
+        setFocusableInTouchMode(true);
+        requestFocus();
+        setOnKeyListener(this);
+        setOnTouchListener(this);
+        enableSensor(Sensor.TYPE_ACCELEROMETER, true);
+    }
+
+    public Surface getNativeSurface() {
+        return getHolder().getSurface();
+    }
+
+    // Called when we have a valid drawing surface
+    @Override
+    public void surfaceCreated(SurfaceHolder holder) {
+        Log.v("SDL", "surfaceCreated()");
+        SDLActivity.onNativeSurfaceCreated();
+    }
+
+    // Called when we lose the surface
+    @Override
+    public void surfaceDestroyed(SurfaceHolder holder) {
+        Log.v("SDL", "surfaceDestroyed()");
+
+        // Transition to pause, if needed
+        SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
+        SDLActivity.handleNativeState();
+
+        mIsSurfaceReady = false;
+        SDLActivity.onNativeSurfaceDestroyed();
+    }
+
+    // Called when the surface is resized
+    @Override
+    public void surfaceChanged(SurfaceHolder holder,
+                               int format, int width, int height) {
+        Log.v("SDL", "surfaceChanged()");
+
+        if (SDLActivity.mSingleton == null) {
+            return;
+        }
+
+        mWidth = width;
+        mHeight = height;
+        int nDeviceWidth = width;
+        int nDeviceHeight = height;
+        try
+        {
+            if (Build.VERSION.SDK_INT >= 17) {
+                DisplayMetrics realMetrics = new DisplayMetrics();
+                mDisplay.getRealMetrics( realMetrics );
+                nDeviceWidth = realMetrics.widthPixels;
+                nDeviceHeight = realMetrics.heightPixels;
+            }
+        } catch(Exception ignored) {
+        }
+
+        synchronized(SDLActivity.getContext()) {
+            // In case we're waiting on a size change after going fullscreen, send a notification.
+            SDLActivity.getContext().notifyAll();
+        }
+
+        Log.v("SDL", "Window size: " + width + "x" + height);
+        Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
+        SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, mDisplay.getRefreshRate());
+        SDLActivity.onNativeResize();
+
+        // Prevent a screen distortion glitch,
+        // for instance when the device is in Landscape and a Portrait App is resumed.
+        boolean skip = false;
+        int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
+
+        if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
+            if (mWidth > mHeight) {
+               skip = true;
+            }
+        } else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
+            if (mWidth < mHeight) {
+               skip = true;
+            }
+        }
+
+        // Special Patch for Square Resolution: Black Berry Passport
+        if (skip) {
+           double min = Math.min(mWidth, mHeight);
+           double max = Math.max(mWidth, mHeight);
+
+           if (max / min < 1.20) {
+              Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
+              skip = false;
+           }
+        }
+
+        // Don't skip in MultiWindow.
+        if (skip) {
+            if (Build.VERSION.SDK_INT >= 24) {
+                if (SDLActivity.mSingleton.isInMultiWindowMode()) {
+                    Log.v("SDL", "Don't skip in Multi-Window");
+                    skip = false;
+                }
+            }
+        }
+
+        if (skip) {
+           Log.v("SDL", "Skip .. Surface is not ready.");
+           mIsSurfaceReady = false;
+           return;
+        }
+
+        /* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
+        SDLActivity.onNativeSurfaceChanged();
+
+        /* Surface is ready */
+        mIsSurfaceReady = true;
+
+        SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
+        SDLActivity.handleNativeState();
+    }
+
+    // Key events
+    @Override
+    public boolean onKey(View v, int keyCode, KeyEvent event) {
+        return SDLActivity.handleKeyEvent(v, keyCode, event, null);
+    }
+
+    // Touch events
+    @Override
+    public boolean onTouch(View v, MotionEvent event) {
+        /* Ref: http://developer.android.com/training/gestures/multi.html */
+        int touchDevId = event.getDeviceId();
+        final int pointerCount = event.getPointerCount();
+        int action = event.getActionMasked();
+        int pointerFingerId;
+        int i = -1;
+        float x,y,p;
+
+        /*
+         * Prevent id to be -1, since it's used in SDL internal for synthetic events
+         * Appears when using Android emulator, eg:
+         *  adb shell input mouse tap 100 100
+         *  adb shell input touchscreen tap 100 100
+         */
+        if (touchDevId < 0) {
+            touchDevId -= 1;
+        }
+
+        // 12290 = Samsung DeX mode desktop mouse
+        // 12290 = 0x3002 = 0x2002 | 0x1002 = SOURCE_MOUSE | SOURCE_TOUCHSCREEN
+        // 0x2   = SOURCE_CLASS_POINTER
+        if (event.getSource() == InputDevice.SOURCE_MOUSE || event.getSource() == (InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN)) {
+            int mouseButton = 1;
+            try {
+                Object object = event.getClass().getMethod("getButtonState").invoke(event);
+                if (object != null) {
+                    mouseButton = (Integer) object;
+                }
+            } catch(Exception ignored) {
+            }
+
+            // We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
+            // if we are.  We'll leverage our existing mouse motion listener
+            SDLGenericMotionListener_API12 motionListener = SDLActivity.getMotionListener();
+            x = motionListener.getEventX(event);
+            y = motionListener.getEventY(event);
+
+            SDLActivity.onNativeMouse(mouseButton, action, x, y, motionListener.inRelativeMode());
+        } else {
+            switch(action) {
+                case MotionEvent.ACTION_MOVE:
+                    for (i = 0; i < pointerCount; i++) {
+                        pointerFingerId = event.getPointerId(i);
+                        x = event.getX(i) / mWidth;
+                        y = event.getY(i) / mHeight;
+                        p = event.getPressure(i);
+                        if (p > 1.0f) {
+                            // may be larger than 1.0f on some devices
+                            // see the documentation of getPressure(i)
+                            p = 1.0f;
+                        }
+                        SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
+                    }
+                    break;
+
+                case MotionEvent.ACTION_UP:
+                case MotionEvent.ACTION_DOWN:
+                    // Primary pointer up/down, the index is always zero
+                    i = 0;
+                    /* fallthrough */
+                case MotionEvent.ACTION_POINTER_UP:
+                case MotionEvent.ACTION_POINTER_DOWN:
+                    // Non primary pointer up/down
+                    if (i == -1) {
+                        i = event.getActionIndex();
+                    }
+
+                    pointerFingerId = event.getPointerId(i);
+                    x = event.getX(i) / mWidth;
+                    y = event.getY(i) / mHeight;
+                    p = event.getPressure(i);
+                    if (p > 1.0f) {
+                        // may be larger than 1.0f on some devices
+                        // see the documentation of getPressure(i)
+                        p = 1.0f;
+                    }
+                    SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
+                    break;
+
+                case MotionEvent.ACTION_CANCEL:
+                    for (i = 0; i < pointerCount; i++) {
+                        pointerFingerId = event.getPointerId(i);
+                        x = event.getX(i) / mWidth;
+                        y = event.getY(i) / mHeight;
+                        p = event.getPressure(i);
+                        if (p > 1.0f) {
+                            // may be larger than 1.0f on some devices
+                            // see the documentation of getPressure(i)
+                            p = 1.0f;
+                        }
+                        SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
+                    }
+                    break;
+
+                default:
+                    break;
+            }
+        }
+
+        return true;
+   }
+
+    // Sensor events
+    public void enableSensor(int sensortype, boolean enabled) {
+        // TODO: This uses getDefaultSensor - what if we have >1 accels?
+        if (enabled) {
+            mSensorManager.registerListener(this,
+                            mSensorManager.getDefaultSensor(sensortype),
+                            SensorManager.SENSOR_DELAY_GAME, null);
+        } else {
+            mSensorManager.unregisterListener(this,
+                            mSensorManager.getDefaultSensor(sensortype));
+        }
+    }
+
+    @Override
+    public void onAccuracyChanged(Sensor sensor, int accuracy) {
+        // TODO
+    }
+
+    @Override
+    public void onSensorChanged(SensorEvent event) {
+        if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
+
+            // Since we may have an orientation set, we won't receive onConfigurationChanged events.
+            // We thus should check here.
+            int newOrientation;
+
+            float x, y;
+            switch (mDisplay.getRotation()) {
+                case Surface.ROTATION_90:
+                    x = -event.values[1];
+                    y = event.values[0];
+                    newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE;
+                    break;
+                case Surface.ROTATION_270:
+                    x = event.values[1];
+                    y = -event.values[0];
+                    newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE_FLIPPED;
+                    break;
+                case Surface.ROTATION_180:
+                    x = -event.values[0];
+                    y = -event.values[1];
+                    newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT_FLIPPED;
+                    break;
+                case Surface.ROTATION_0:
+                default:
+                    x = event.values[0];
+                    y = event.values[1];
+                    newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT;
+                    break;
+            }
+
+            if (newOrientation != SDLActivity.mCurrentOrientation) {
+                SDLActivity.mCurrentOrientation = newOrientation;
+                SDLActivity.onNativeOrientationChanged(newOrientation);
+            }
+
+            SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
+                                      y / SensorManager.GRAVITY_EARTH,
+                                      event.values[2] / SensorManager.GRAVITY_EARTH);
+
+
+        }
+    }
+
+    // Captured pointer events for API 26.
+    public boolean onCapturedPointerEvent(MotionEvent event)
+    {
+        int action = event.getActionMasked();
+
+        float x, y;
+        switch (action) {
+            case MotionEvent.ACTION_SCROLL:
+                x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
+                y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
+                SDLActivity.onNativeMouse(0, action, x, y, false);
+                return true;
+
+            case MotionEvent.ACTION_HOVER_MOVE:
+            case MotionEvent.ACTION_MOVE:
+                x = event.getX(0);
+                y = event.getY(0);
+                SDLActivity.onNativeMouse(0, action, x, y, true);
+                return true;
+
+            case MotionEvent.ACTION_BUTTON_PRESS:
+            case MotionEvent.ACTION_BUTTON_RELEASE:
+
+                // Change our action value to what SDL's code expects.
+                if (action == MotionEvent.ACTION_BUTTON_PRESS) {
+                    action = MotionEvent.ACTION_DOWN;
+                } else { /* MotionEvent.ACTION_BUTTON_RELEASE */
+                    action = MotionEvent.ACTION_UP;
+                }
+
+                x = event.getX(0);
+                y = event.getY(0);
+                int button = event.getButtonState();
+
+                SDLActivity.onNativeMouse(button, action, x, y, true);
+                return true;
+        }
+
+        return false;
+    }
+}

BIN
android/vcmi-app/src/main/res/drawable-nodpi/divider_compat.png


+ 30 - 0
android/vcmi-app/src/main/res/drawable-v24/ic_launcher_foreground.xml

@@ -0,0 +1,30 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:aapt="http://schemas.android.com/aapt"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
+        <aapt:attr name="android:fillColor">
+            <gradient
+                android:endX="85.84757"
+                android:endY="92.4963"
+                android:startX="42.9492"
+                android:startY="49.59793"
+                android:type="linear">
+                <item
+                    android:color="#44000000"
+                    android:offset="0.0" />
+                <item
+                    android:color="#00000000"
+                    android:offset="1.0" />
+            </gradient>
+        </aapt:attr>
+    </path>
+    <path
+        android:fillColor="#FFFFFF"
+        android:fillType="nonZero"
+        android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
+        android:strokeColor="#00000000"
+        android:strokeWidth="1" />
+</vector>

+ 4 - 0
android/vcmi-app/src/main/res/drawable/compat_toolbar_shadow.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <gradient android:startColor="#000000" android:endColor="#00000000" android:angle="270"/>
+</shape>

+ 9 - 0
android/vcmi-app/src/main/res/drawable/ic_error.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z" />
+</vector>

+ 9 - 0
android/vcmi-app/src/main/res/drawable/ic_info.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="24dp"
+    android:height="24dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M11,17h2v-6h-2v6zM12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM11,9h2L13,7h-2v2z" />
+</vector>

+ 170 - 0
android/vcmi-app/src/main/res/drawable/ic_launcher_background.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportHeight="108"
+    android:viewportWidth="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeColor="#33FFFFFF"
+        android:strokeWidth="0.8" />
+</vector>

+ 9 - 0
android/vcmi-app/src/main/res/drawable/ic_star_empty.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="40dp"
+    android:height="40dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28 -3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
+</vector>

+ 9 - 0
android/vcmi-app/src/main/res/drawable/ic_star_full.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="40dp"
+    android:height="40dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z" />
+</vector>

+ 9 - 0
android/vcmi-app/src/main/res/drawable/ic_star_half.xml

@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="40dp"
+    android:height="40dp"
+    android:viewportHeight="24.0"
+    android:viewportWidth="24.0">
+    <path
+        android:fillColor="#FFFFFFFF"
+        android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4V6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z" />
+</vector>

+ 6 - 0
android/vcmi-app/src/main/res/drawable/overlay_edittext_background.xml

@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <corners android:radius="4dp" />
+    <solid android:color="#A0000000" />
+    <stroke android:color="@color/accent" android:width="1dp" />
+</shape>

+ 7 - 0
android/vcmi-app/src/main/res/drawable/recycler_divider_drawable.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<shape xmlns:android="http://schemas.android.com/apk/res/android">
+    <size
+        android:width="1px"
+        android:height="1px" />
+    <solid android:color="@color/accent" />
+</shape>

+ 19 - 0
android/vcmi-app/src/main/res/layout-v21/inc_toolbar.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout>
+    <com.google.android.material.appbar.AppBarLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        xmlns:app="http://schemas.android.com/apk/res-auto"
+        android:layout_width="match_parent"
+        android:layout_height="?attr/actionBarSize"
+        android:background="@color/bgMain">
+
+        <androidx.appcompat.widget.Toolbar
+            android:id="@+id/toolbar"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:background="@color/bgMain"
+            android:elevation="6dp"
+            app:elevation="6dp"
+            app:title="@string/launcher_title" />
+    </com.google.android.material.appbar.AppBarLayout>
+</layout>

+ 76 - 0
android/vcmi-app/src/main/res/layout/activity_about.xml

@@ -0,0 +1,76 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:layout_below="@+id/toolbar_include">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical"
+            android:padding="@dimen/side_margin">
+
+            <androidx.appcompat.widget.AppCompatTextView
+                style="@style/VCMI.Text.Header"
+                android:text="@string/app_name" />
+
+            <androidx.appcompat.widget.AppCompatTextView
+                android:id="@+id/about_version_app"
+                style="@style/VCMI.Text"
+                android:text="@string/about_version_app" />
+
+            <androidx.appcompat.widget.AppCompatTextView
+                android:id="@+id/about_version_launcher"
+                style="@style/VCMI.Text"
+                android:text="@string/about_version_launcher" />
+        </LinearLayout>
+
+        <include layout="@layout/inc_separator" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:text="@string/about_section_project" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_link_portal"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_links_main" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_link_repo_main"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_links_repo" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_link_repo_launcher"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_links_repo_launcher" />
+
+        <include layout="@layout/inc_separator" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:text="@string/about_section_legal" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_btn_authors"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_btn_authors" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_btn_libs"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_btn_libs" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/about_btn_privacy"
+            style="@style/VCMI.Entry.Clickable.AboutSimpleEntry"
+            android:text="@string/about_btn_privacy" />
+    </LinearLayout>
+</ScrollView>

+ 22 - 0
android/vcmi-app/src/main/res/layout/activity_error.xml

@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical">
+
+    <androidx.appcompat.widget.AppCompatTextView
+        android:id="@+id/error_message"
+        style="@style/VCMI.Text"
+        android:layout_margin="@dimen/side_margin"
+        android:text="@string/app_name" />
+
+    <androidx.appcompat.widget.AppCompatButton
+        android:id="@+id/error_btn_try_again"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="right"
+        android:layout_margin="@dimen/side_margin"
+        android:layout_marginTop="20dp"
+        android:text="@string/misc_try_again" />
+</LinearLayout>

+ 19 - 0
android/vcmi-app/src/main/res/layout/activity_game.xml

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="horizontal">
+
+    <RelativeLayout
+        android:id="@+id/game_outer_frame"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent" />
+
+    <ProgressBar
+        android:id="@+id/game_progress"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center"
+        android:visibility="gone" />
+</FrameLayout>

+ 106 - 0
android/vcmi-app/src/main/res/layout/activity_launcher.xml

@@ -0,0 +1,106 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:layout_below="@+id/toolbar_include"
+    android:clipChildren="false"
+    android:clipToPadding="false">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="10dp"
+        android:orientation="vertical">
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/launcher_version_info"
+            style="@style/VCMI.Text"
+            android:padding="@dimen/side_margin"
+            android:text="@string/app_name" />
+
+        <include layout="@layout/inc_separator" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:elevation="2dp"
+            android:text="@string/launcher_section_init"
+            app:elevation="2dp" />
+
+        <FrameLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content">
+
+            <include
+                android:id="@+id/launcher_btn_start"
+                layout="@layout/inc_launcher_btn" />
+
+            <androidx.appcompat.widget.AppCompatTextView
+                android:id="@+id/launcher_error"
+                style="@style/VCMI.Text"
+                android:drawableLeft="@drawable/ic_error"
+                android:drawablePadding="10dp"
+                android:gravity="center_vertical"
+                android:minHeight="80dp"
+                android:padding="@dimen/side_margin"
+                android:text="@string/app_name" />
+
+            <ProgressBar
+                android:id="@+id/launcher_progress"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_gravity="center" />
+        </FrameLayout>
+
+        <include
+            android:id="@+id/launcher_btn_copy"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_export"
+            layout="@layout/inc_launcher_btn" />
+
+        <include layout="@layout/inc_separator" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:text="@string/launcher_section_settings" />
+
+        <include
+            android:id="@+id/launcher_btn_mods"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_res"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_adventure_ai"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_cp"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_pointer_mode"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_pointer_multi"
+            layout="@layout/inc_launcher_btn" />
+
+        <include
+            android:id="@+id/launcher_btn_volume_sound"
+            layout="@layout/inc_launcher_slider" />
+
+        <include
+            android:id="@+id/launcher_btn_volume_music"
+            layout="@layout/inc_launcher_slider" />
+    </LinearLayout>
+</ScrollView>

+ 29 - 0
android/vcmi-app/src/main/res/layout/activity_mods.xml

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<FrameLayout
+    android:id="@+id/mods_data_root"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:layout_below="@+id/toolbar_include"
+    android:clipChildren="false"
+    android:clipToPadding="false">
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/mods_recycler"
+        xmlns:tools="http://schemas.android.com/tools"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        tools:listitem="@layout/mods_adapter_item" />
+
+    <TextView
+        android:id="@+id/mods_error_text"
+        style="@style/VCMI.Text"
+        android:layout_marginTop="30dp"
+        android:gravity="center" />
+
+    <ProgressBar
+        android:id="@+id/mods_progress"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_gravity="center" />
+</FrameLayout>

+ 18 - 0
android/vcmi-app/src/main/res/layout/activity_toolbar_wrapper.xml

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+    <RelativeLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent">
+
+        <include
+            android:id="@+id/toolbar_include"
+            layout="@layout/inc_toolbar" />
+
+        <ViewStub
+            android:id="@+id/toolbar_wrapper_content_stub"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent"
+            android:layout_below="@id/toolbar_include" />
+
+    </RelativeLayout>
+</layout>

+ 34 - 0
android/vcmi-app/src/main/res/layout/dialog_authors.xml

@@ -0,0 +1,34 @@
+<?xml version="1.0" encoding="utf-8"?>
+<ScrollView
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:orientation="vertical">
+
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:text="@string/dialog_authors_vcmi" />
+
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/dialog_authors_vcmi"
+            style="@style/VCMI.Text"
+            android:padding="@dimen/side_margin" />
+
+        <include layout="@layout/inc_separator" />
+
+        <!-- TODO should this be separate or just merged with vcmi authors? -->
+        <androidx.appcompat.widget.AppCompatTextView
+            style="@style/VCMI.Text.LauncherSection"
+            android:text="@string/dialog_authors_launcher" />
+
+        <androidx.appcompat.widget.AppCompatTextView
+            android:id="@+id/dialog_authors_launcher"
+            style="@style/VCMI.Text"
+            android:padding="@dimen/side_margin" />
+    </LinearLayout>
+</ScrollView>

+ 33 - 0
android/vcmi-app/src/main/res/layout/inc_launcher_btn.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+    <data>
+        <variable
+            name="title"
+            type="java.lang.String" />
+        <variable
+            name="description"
+            type="java.lang.String" />
+    </data>
+    <RelativeLayout
+        style="@style/VCMI.Entry.Clickable"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_centerVertical="true"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/inc_launcher_btn_main"
+                style="@style/VCMI.Text.LauncherEntry"
+                android:text="@{title}" />
+
+            <TextView
+                android:id="@+id/inc_launcher_btn_sub"
+                style="@style/VCMI.Text.LauncherEntry.Sub"
+                android:text="@{description}" />
+        </LinearLayout>
+    </RelativeLayout>
+</layout>

+ 27 - 0
android/vcmi-app/src/main/res/layout/inc_launcher_slider.xml

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+    <RelativeLayout
+        style="@style/VCMI.Entry"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_centerVertical="true"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/inc_launcher_btn_main"
+                style="@style/VCMI.Text.LauncherEntry"
+                android:text="@string/app_name" />
+
+            <androidx.appcompat.widget.AppCompatSeekBar
+                android:id="@+id/inc_launcher_btn_slider"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:paddingTop="8dp"
+                android:paddingBottom="8dp" />
+        </LinearLayout>
+    </RelativeLayout>
+</layout>

+ 7 - 0
android/vcmi-app/src/main/res/layout/inc_separator.xml

@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android">
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="1px"
+        android:background="@color/separator" />
+</layout>

Some files were not shown because too many files changed in this diff