diff --git a/CMakeLists.txt b/CMakeLists.txt
index 417111e6c..24909f7d1 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -51,6 +51,8 @@ if(EMSCRIPTEN)
add_compile_options(-O1)
endif()
+ set(INCLUDE_NATIVE_COMPONENTS OFF)
+elseif(ANDROID)
set(INCLUDE_NATIVE_COMPONENTS OFF)
else()
set(INCLUDE_NATIVE_COMPONENTS ON)
@@ -78,8 +80,28 @@ option(FORCE_VENDORED_Eigen3
set(OPENGL 3 CACHE STRING "OpenGL version to use (one of: 1 3)")
+if(ANDROID)
+ set(ANDROID_JAR CACHE STRING "Path to android.jar")
+ set(BUILD_TOOL CACHE STRING "Path to build tool root")
+ if(NOT BUILD_TOOL)
+ message(FATAL_ERROR "Require BUILD_TOOL")
+ endif()
+ if(NOT ANDROID_JAR)
+ message(FATAL_ERROR "Require ANDROID_JAR")
+ endif()
+ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+ set(CMAKE_SHARED_LINKER_FLAGS "-static-libstdc++")
+ # for termux to find liblog, libandroid
+ set(CMAKE_PATH_PREFIX "/system/lib64;${CMAKE_PATH_PREFIX}")
+endif()
+
set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin)
-set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin)
+if(ANDROID)
+ set(ANDROID_ABI arm64-v8a CACHE STRING "Android ABI")
+ set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR}/lib/${ANDROID_ABI})
+else()
+ set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin)
+endif()
if("${CMAKE_GENERATOR}" STREQUAL "Xcode")
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY $<1:${CMAKE_BINARY_DIR}/bin>)
endif()
@@ -139,6 +161,9 @@ if(ENABLE_OPENMP)
if(OPENMP_FOUND)
add_library(slvs_openmp INTERFACE)
target_compile_options(slvs_openmp INTERFACE "SHELL:${OpenMP_CXX_FLAGS}")
+ if(ANDROID)
+ set(OpenMP_CXX_LIBRARIES -fopenmp -static-openmp)
+ endif()
target_link_libraries(slvs_openmp INTERFACE
${OpenMP_CXX_LIBRARIES})
target_include_directories(slvs_openmp SYSTEM INTERFACE
@@ -278,7 +303,7 @@ endif()
if(ENABLE_GUI OR ENABLE_CLI)
- if(WIN32 OR APPLE OR EMSCRIPTEN)
+ if(WIN32 OR APPLE OR EMSCRIPTEN OR ANDROID)
# On Win32 and macOS we use vendored packages, since there is little to no benefit
# to trying to find system versions. In particular, trying to link to libraries from
# Homebrew or macOS system libraries into the .app file is highly likely to result
@@ -287,7 +312,9 @@ if(ENABLE_GUI OR ENABLE_CLI)
include(FindVendoredPackage)
include(AddVendoredSubdirectory)
- set(FORCE_VENDORED_ZLIB ON)
+ if(NOT ANDROID)
+ set(FORCE_VENDORED_ZLIB ON)
+ endif()
set(FORCE_VENDORED_PNG ON)
set(FORCE_VENDORED_Freetype ON)
@@ -300,14 +327,19 @@ if(ENABLE_GUI OR ENABLE_CLI)
# generate an imported target since we'll have proper targets, but libpng would
# still need to be updated to use ZLIB::ZLIBSTATIC instead of ZLIB::ZLIB, so this
# ugly hack will have to stay until both libraries release fixed versions.
- add_vendored_subdirectory(extlib/zlib)
- set(ZLIB_LIBRARY zlibstatic CACHE INTERNAL "")
- set(ZLIB_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/extlib/zlib CACHE INTERNAL "")
- add_library(ZLIB::ZLIB ALIAS ${ZLIB_LIBRARY})
- find_package(ZLIB REQUIRED)
- set(ZLIB_FOUND ${ZLIB_FOUND} CACHE INTERNAL "")
-
- list(APPEND ZLIB_INCLUDE_DIR ${CMAKE_BINARY_DIR}/extlib/zlib)
+ if(ANDROID)
+ find_library(log-lib log)
+ find_library(droid-lib android)
+ else()
+ add_vendored_subdirectory(extlib/zlib)
+ set(ZLIB_LIBRARY zlibstatic CACHE INTERNAL "")
+ set(ZLIB_INCLUDE_DIR ${CMAKE_SOURCE_DIR}/extlib/zlib CACHE INTERNAL "")
+ add_library(ZLIB::ZLIB ALIAS ${ZLIB_LIBRARY})
+ find_package(ZLIB REQUIRED)
+ set(ZLIB_FOUND ${ZLIB_FOUND} CACHE INTERNAL "")
+
+ list(APPEND ZLIB_INCLUDE_DIR ${CMAKE_BINARY_DIR}/extlib/zlib)
+ endif()
# There's an issue with the zlib configuration, which doesn't provide a way to
# disable the shared or static library builds, and on UNIX sets the base name of
@@ -343,22 +375,24 @@ if(ENABLE_GUI OR ENABLE_CLI)
FREETYPE_LIBRARY freetype
FREETYPE_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/extlib/freetype/include)
- message(STATUS "Using in-tree pixman")
- set(PIXMAN_FOUND YES)
- set(PIXMAN_LIBRARY pixman)
- set(PIXMAN_BUILD_TESTS OFF CACHE BOOL "")
- set(PIXMAN_BUILD_DEMOS OFF CACHE BOOL "")
-
- set(PIXMAN_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/extlib/pixman/pixman)
- list(APPEND PIXMAN_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/extlib/pixman/pixman)
- add_vendored_subdirectory(extlib/pixman)
-
- message(STATUS "Using in-tree cairo")
- add_vendored_subdirectory(extlib/cairo)
- set(CAIRO_FOUND YES)
- set(CAIRO_LIBRARIES cairo)
- set(CAIRO_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/extlib/cairo/src)
- list(APPEND CAIRO_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/extlib/cairo/src)
+ if(NOT ANDROID)
+ message(STATUS "Using in-tree pixman")
+ set(PIXMAN_FOUND YES)
+ set(PIXMAN_LIBRARY pixman)
+ set(PIXMAN_BUILD_TESTS OFF CACHE BOOL "")
+ set(PIXMAN_BUILD_DEMOS OFF CACHE BOOL "")
+
+ set(PIXMAN_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/extlib/pixman/pixman)
+ list(APPEND PIXMAN_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/extlib/pixman/pixman)
+ add_vendored_subdirectory(extlib/pixman)
+
+ message(STATUS "Using in-tree cairo")
+ add_vendored_subdirectory(extlib/cairo)
+ set(CAIRO_FOUND YES)
+ set(CAIRO_LIBRARIES cairo)
+ set(CAIRO_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/extlib/cairo/src)
+ list(APPEND CAIRO_INCLUDE_DIRS ${CMAKE_BINARY_DIR}/extlib/cairo/src)
+ endif()
else()
# On Linux and BSDs we're a good citizen and link to system libraries.
find_package(Backtrace)
@@ -405,6 +439,8 @@ if(ENABLE_GUI)
find_library(APPKIT_LIBRARY AppKit REQUIRED)
elseif(EMSCRIPTEN)
# Everything is built in
+ elseif(ANDROID)
+ set(OPENGL_LIBRARIES EGL GLESv2)
else()
find_package(OpenGL REQUIRED)
find_package(SpaceWare)
@@ -533,7 +569,7 @@ add_subdirectory(src)
if(ENABLE_TESTS)
add_subdirectory(test)
endif()
-if((ENABLE_CLI OR ENABLE_GUI) AND (CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo"))
+if((ENABLE_CLI OR ENABLE_GUI) AND (CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo") AND (NOT ANDROID))
add_subdirectory(bench)
else()
message(STATUS "Benchmarking disabled in debug and library builds.")
diff --git a/README.md b/README.md
index a4513b922..0f29a0dd4 100644
--- a/README.md
+++ b/README.md
@@ -346,6 +346,70 @@ ninja
[mingw]: http://www.mingw.org/
[msys2]: https://www.msys2.org/
+## Building for Android (Experimental)
+
+> [!NOTE]
+> This port contains many critical bugs and unimplemented core functions.
+
+Before building, [check out the project and the necessary submodules](#via-source-code).
+
+> [!TIP]
+> Android port doesn't need extlib/angle, extlib/cairo, extlib/pixman, extlib/zlib submodules.
+
+### Building with Android NDK
+
+First, install Android NDK and Build-Tools via sdkmanager(CLI or Android Studio) or download from [website](https://developer.android.com/ndk/downloads/)
+
+and install Java, make/ninja and cmake:
+
+```sh
+# Ubuntu/Debian
+sudo apt install default-jre cmake make
+# macOS
+brew install openjdk@17 cmake make
+# Windows
+winget install --id EclipseAdoptium.Temurin.17.JDK -e -h
+winget install --id Kitware.CMake -e -h
+winget install --id Ninja-build.Ninja -e -h
+```
+
+Also keep an `android.jar` file from Android Platforms or elsewhere like
+[Sable/android-platforms](//github.com/Sable/android-platforms).
+
+After that, build SolveSpace as following:
+
+```sh
+cmake -Bbuild -S. -DCMAKE_TOOLCHAIN_FILE=$ANDROID_SDK_ROOT/ndk/xx.xx/build/cmake/android.toolchain.cmake
+ -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$ANDROID_SDK_ROOT/build-tool/xx.xx/
+ -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-21
+ -DENABLE_LTO=ON -DENABLE_OPENMP=ON -DCMAKE_BUILD_TYPE=Debug
+cmake --build build -j8
+```
+
+This will build an app.apk in the `build/bin` directory, and then install it with
+`adb install --no-streaming build/bin/app.apk`.
+
+### Building on Termux
+
+First, install tools for building apk:
+
+```sh
+pkg i openjdk-21 d8 aapt apksigner cmake make clang ndk-multilib eigen zlib
+wget -c -k -O /path/to/android.jar https://raw.githubusercontent.com/Sable/android-platforms/master/android-36/android.jar
+```
+
+After that, build SolveSpace as following:
+
+```sh
+cmake -Bbuild -S.
+ -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$PREFIX/bin
+ -DANDROID_ABI=arm64-v8a -DENABLE_OPENMP=ON -DENABLE_LTO=ON -DCMAKE_BUILD_TYPE=Debug
+ -DZLIB_ROOT=/system/lib64 -DEIGEN3_INCLUDE_DIRS=$PREFIX/include/eigen3
+make -Cbuild -j8
+```
+
+To install the generated apk, run `xdg-open build/bin/app.apk`.
+
## Contributing
See the [guide for contributors](CONTRIBUTING.md) for the best way to file issues, contribute code,
diff --git a/res/AndroidManifest.xml b/res/AndroidManifest.xml
new file mode 100644
index 000000000..6c8d5e9fd
--- /dev/null
+++ b/res/AndroidManifest.xml
@@ -0,0 +1,31 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/CMakeLists.txt b/res/CMakeLists.txt
index c4f958ed4..e63fb25e1 100644
--- a/res/CMakeLists.txt
+++ b/res/CMakeLists.txt
@@ -88,10 +88,13 @@ elseif(EMSCRIPTEN)
set(source ${CMAKE_CURRENT_SOURCE_DIR}/${name})
set(resource_list "${resource_list};${source}" PARENT_SCOPE)
endfunction()
-else() # Unix
- include(GNUInstallDirs)
-
- set(app_resource_dir ${CMAKE_BINARY_DIR}/res)
+else() # Unix & Android
+ if(ANDROID)
+ set(app_resource_dir ${CMAKE_BINARY_DIR}/assets)
+ else()
+ include(GNUInstallDirs)
+ set(app_resource_dir ${CMAKE_BINARY_DIR}/res)
+ endif()
function(add_resource name)
set(source ${CMAKE_CURRENT_SOURCE_DIR}/${name})
@@ -107,9 +110,11 @@ else() # Unix
DEPENDS ${source}
VERBATIM)
- get_filename_component(name_dir ${name} DIRECTORY)
- install(FILES ${source}
- DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/solvespace/${name_dir})
+ if (NOT ANDROID)
+ get_filename_component(name_dir ${name} DIRECTORY)
+ install(FILES ${source}
+ DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/solvespace/${name_dir})
+ endif()
endfunction()
endif()
@@ -128,7 +133,7 @@ elseif(APPLE)
add_iconset (cocoa/AppIcon.iconset)
add_xib (cocoa/MainMenu.xib)
add_xib (cocoa/SaveFormatAccessory.xib)
-else()
+elseif(NOT ANDROID)
add_resource(freedesktop/solvespace-48x48.png)
if(FLATPAK)
@@ -324,4 +329,33 @@ add_custom_target(resources
SOURCES ${resource_list})
if(WIN32)
set_property(TARGET resources PROPERTY EXTRA_SOURCES ${rc_file})
+elseif(ANDROID)
+ set(droid_res ${resource_root}/android)
+ file(GLOB_RECURSE res_list ${droid_res}/*)
+ set(MIN_SDK 21)
+ set(TAG_SDK 34)
+ math(EXPR VERCODE "${PROJECT_VERSION_MAJOR}*100000+${PROJECT_VERSION_MINOR}*1000")
+ set(VERNAME "${PROJECT_VERSION}~${solvespace_GIT_HASH}")
+ if(CMAKE_BUILD_TYPE STREQUAL "Debug")
+ set(debugmode --debug-mode)
+ else()
+ set(debugmode "")
+ endif()
+ add_custom_target(aapt_pre
+ BYPRODUCTS ${CMAKE_BINARY_DIR}/bin/res.apk ${CMAKE_BINARY_DIR}/gen/com/solvespace/R.java
+ COMMAND ${CMAKE_COMMAND} -E make_directory ${CMAKE_BINARY_DIR}/gen
+ COMMAND ${BUILD_TOOL}/aapt p -f -m
+ -F ${CMAKE_BINARY_DIR}/bin/res.apk
+ -I ${ANDROID_JAR}
+ -S ${droid_res}
+ -A ${app_resource_dir}
+ -M ${resource_root}/AndroidManifest.xml
+ ${debugmode}
+ --version-code ${VERCODE}
+ --version-name ${VERNAME}
+ --min-sdk-version ${MIN_SDK}
+ --target-sdk-version ${TAG_SDK}
+ -J ${CMAKE_BINARY_DIR}/gen
+ COMMENT "aapt pack"
+ DEPENDS ${res_list} ${resource_root}/AndroidManifest.xml)
endif()
diff --git a/res/android/drawable-hdpi/ic_launcher.png b/res/android/drawable-hdpi/ic_launcher.png
new file mode 100644
index 000000000..99847c219
Binary files /dev/null and b/res/android/drawable-hdpi/ic_launcher.png differ
diff --git a/res/android/drawable-mdpi/ic_launcher.png b/res/android/drawable-mdpi/ic_launcher.png
new file mode 100644
index 000000000..cd22c42d8
Binary files /dev/null and b/res/android/drawable-mdpi/ic_launcher.png differ
diff --git a/res/android/drawable-xhdpi/ic_launcher.png b/res/android/drawable-xhdpi/ic_launcher.png
new file mode 100644
index 000000000..a2545e12d
Binary files /dev/null and b/res/android/drawable-xhdpi/ic_launcher.png differ
diff --git a/res/android/drawable-xxhdpi/ic_launcher.png b/res/android/drawable-xxhdpi/ic_launcher.png
new file mode 100644
index 000000000..00ce5024b
Binary files /dev/null and b/res/android/drawable-xxhdpi/ic_launcher.png differ
diff --git a/res/android/layout/main.xml b/res/android/layout/main.xml
new file mode 100644
index 000000000..3e4f1ca7b
--- /dev/null
+++ b/res/android/layout/main.xml
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/res/android/values/ids.xml b/res/android/values/ids.xml
new file mode 100644
index 000000000..ccb4e5fa7
--- /dev/null
+++ b/res/android/values/ids.xml
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/res/android/values/string.xml b/res/android/values/string.xml
new file mode 100644
index 000000000..ebe2bb60b
--- /dev/null
+++ b/res/android/values/string.xml
@@ -0,0 +1,4 @@
+
+ Yes
+ No
+
\ No newline at end of file
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 3af207599..3227730bf 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -38,7 +38,7 @@ target_link_libraries(slvs_deps INTERFACE
${FREETYPE_LIBRARY}
mimalloc-static)
-if(NOT USE_QT_GUI OR ENABLE_CLI)
+if(NOT USE_QT_GUI AND NOT ANDROID OR ENABLE_CLI)
target_include_directories(slvs_deps SYSTEM INTERFACE ${CAIRO_INCLUDE_DIRS})
target_link_libraries(slvs_deps INTERFACE ${CAIRO_LIBRARIES})
endif()
@@ -106,7 +106,8 @@ if(ENABLE_GUI OR ENABLE_CLI)
platform/guiwin.cpp
platform/guigtk.cpp
platform/guimac.mm
- platform/guihtml.cpp)
+ platform/guihtml.cpp
+ platform/guidroid.cpp)
# solvespace library
@@ -172,7 +173,14 @@ if(ENABLE_GUI OR ENABLE_CLI)
${CMAKE_CURRENT_BINARY_DIR})
target_link_libraries(solvespace-core PRIVATE slvs-solver)
target_link_libraries(solvespace-core PUBLIC slvs_deps)
-
+ if(ANDROID)
+ set(CMAKE_POSITION_INDEPENDENT_CODE ON)
+ set_target_properties(solvespace-core PROPERTIES POSITION_INDEPENDENT_CODE ON)
+ target_link_libraries(solvespace-core
+ PUBLIC
+ ${droid-lib}
+ ${log-lib})
+ endif()
# solvespace translations
if(HAVE_GETTEXT)
@@ -260,11 +268,18 @@ endif()
# solvespace graphical executable
if(ENABLE_GUI)
- add_executable(solvespace WIN32 MACOSX_BUNDLE
- ${solvespace_core_gl_SOURCES}
- $)
+ if(ANDROID)
+ add_library(solvespace
+ SHARED
+ ${solvespace_core_gl_SOURCES}
+ $)
+ else()
+ add_executable(solvespace WIN32 MACOSX_BUNDLE
+ ${solvespace_core_gl_SOURCES}
+ $)
+ endif()
- if(NOT USE_QT_GUI)
+ if(NOT USE_QT_GUI AND NOT ANDROID)
target_sources(solvespace PRIVATE platform/entrygui.cpp)
endif()
@@ -400,6 +415,55 @@ if(ENABLE_GUI)
Qt6::Core Qt6::Gui Qt6::OpenGLWidgets Qt6::Widgets
# Qt5::Core Qt5::Gui Qt5::Widgets
)
+ elseif(ANDROID)
+ target_sources(solvespace PRIVATE
+ platform/guidroid.cpp)
+ file(GLOB_RECURSE java_src platform/java/*.java)
+ set(LIBDIR lib/${ANDROID_ABI})
+ if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows")
+ set(HOME $ENV{USERPROFILE})
+ set(LIST_CLS dir /s /b classes\\*.class)
+ else()
+ set(HOME $ENV{HOME})
+ set(LIST_CLS find classes -name *.class)
+ endif()
+ if(CMAKE_BUILD_TYPE STREQUAL "Release")
+ set(strip ${CMAKE_STRIP} -s ${LIBDIR}/libsolvespace.so)
+ else()
+ set(strip "")
+ endif()
+ file(TO_NATIVE_PATH ${HOME}/.android/debug.keystore kspath)
+ add_custom_command(
+ OUTPUT ${CMAKE_BINARY_DIR}/cls.txt
+ COMMAND javac -cp ${ANDROID_JAR} -source 1.8 -target 1.8
+ -d classes
+ ${java_src} gen/com/solvespace/R.java
+ COMMAND ${LIST_CLS} > cls.txt
+ DEPENDS ${java_src} aapt_pre
+ WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+ COMMENT "Compiling java"
+ VERBATIM)
+ add_custom_command(
+ OUTPUT ${CMAKE_BINARY_DIR}/classes.dex
+ COMMAND ${BUILD_TOOL}/d8 --output ./ --lib ${ANDROID_JAR} --min-api 21 @cls.txt
+ DEPENDS ${CMAKE_BINARY_DIR}/cls.txt
+ WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+ COMMENT "Dexing classes to dex"
+ VERBATIM)
+ add_custom_command(
+ OUTPUT ${CMAKE_BINARY_DIR}/bin/app.apk
+ COMMAND ${strip}
+ COMMAND jar -u -f bin/res.apk classes.dex ${LIBDIR}/libsolvespace.so
+ COMMAND ${BUILD_TOOL}/zipalign -f -p 4 bin/res.apk bin/app.apk
+ COMMAND ${BUILD_TOOL}/apksigner sign --ks ${kspath} --ks-pass pass:android --v2-signing-enabled false bin/app.apk
+ DEPENDS ${CMAKE_BINARY_DIR}/classes.dex solvespace
+ WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
+ COMMENT "Packaging"
+ VERBATIM)
+ add_custom_target(gen_apk ALL
+ DEPENDS ${CMAKE_BINARY_DIR}/bin/app.apk)
+ set_directory_properties(PROPERTIES ADDITIONAL_CLEAN_FILES
+ ${CMAKE_BINARY_DIR}/classes/)
else()
target_sources(solvespace PRIVATE
platform/guigtk.cpp)
diff --git a/src/mouse.cpp b/src/mouse.cpp
index 76173bf6a..dd587cf83 100644
--- a/src/mouse.cpp
+++ b/src/mouse.cpp
@@ -810,9 +810,10 @@ void GraphicsWindow::MouseRightUp(double x, double y) {
}
menu->PopUp();
-
+#ifndef __ANDROID__
context.active = false;
SS.ScheduleShowTW();
+#endif
}
hRequest GraphicsWindow::AddRequest(Request::Type type) {
@@ -961,8 +962,8 @@ void GraphicsWindow::MouseLeftDown(double mx, double my, bool shiftDown, bool ct
}
SS.TW.HideEditControl();
- if(SS.showToolbar) {
- if(ToolbarMouseDown((int)mx, (int)my)) return;
+ if(SS.showToolbar && ToolbarMouseDown((int)mx, (int)my)) {
+ return;
}
// This will be clobbered by MouseMoved below.
diff --git a/src/platform/guidroid.cpp b/src/platform/guidroid.cpp
new file mode 100644
index 000000000..e839a0742
--- /dev/null
+++ b/src/platform/guidroid.cpp
@@ -0,0 +1,1382 @@
+//-----------------------------------------------------------------------------
+// The Android-based implementation of platform-dependent GUI functionality.
+//
+// Copyright 2025
+//-----------------------------------------------------------------------------
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include "gui.h"
+#include "solvespace.h"
+
+namespace SolveSpace {
+namespace Platform {
+
+//-----------------------------------------------------------------------------
+// Fatal errors
+//-----------------------------------------------------------------------------
+
+void FatalError(const std::string &message) {
+ android_set_abort_message(message.c_str());
+ abort();
+}
+
+//-----------------------------------------------------------------------------
+// Android-specific JNI interface
+//-----------------------------------------------------------------------------
+
+// Global JNI references
+static JavaVM* g_JavaVM = nullptr;
+static jobject g_Activity = nullptr;
+
+// JNI method IDs
+// SolveSpaceActivity
+static jmethodID g_ShowEditorMethod = nullptr;
+static jmethodID g_HideEditorMethod = nullptr;
+static jmethodID g_IsEditorVisibleMethod = nullptr;
+static jmethodID g_SendDelayedMethod = nullptr;
+static jmethodID g_ShowMessageDialogMethod = nullptr;
+static jmethodID g_ShowFileDialogMethod = nullptr;
+static jmethodID g_SetTitle = nullptr;
+static jmethodID g_OnWinAdded = nullptr;
+static jmethodID g_GetDensity = nullptr;
+static jmethodID g_SetScrollbar = nullptr;
+static jmethodID g_Popup = nullptr;
+static jmethodID g_OpenContentFileMethod = nullptr;
+static jmethodID g_DeleteContentFileMethod = nullptr;
+static jmethodID g_GetInternalStoragePathMethod = nullptr;
+static jmethodID g_Finish = nullptr;
+static jmethodID g_GetSysFonts = nullptr;
+static jmethodID g_InvalidateMenu = nullptr;
+static jmethodID g_FileName = nullptr;
+// Menu
+static jmethodID g_Add = nullptr;
+static jmethodID g_AddSubMenu = nullptr;
+static jmethodID g_Clear = nullptr;
+// MenuItem
+static jmethodID g_GetItemId = nullptr;
+static jmethodID g_SetAlphabeticShortcut = nullptr;
+static jmethodID g_SetCheckable = nullptr;
+static jmethodID g_SetChecked = nullptr;
+static jmethodID g_SetEnabled = nullptr;
+static jmethodID g_SetShowAsAction = nullptr;
+// SharedPreferences
+static jmethodID g_GetPreferences = nullptr;
+static jmethodID g_GetInt = nullptr;
+static jmethodID g_GetFloat = nullptr;
+static jmethodID g_GetString = nullptr;
+static jmethodID g_Edit = nullptr;
+// SharedPreferences$Editor
+static jmethodID g_PutInt = nullptr;
+static jmethodID g_PutFloat = nullptr;
+static jmethodID g_PutString = nullptr;
+static jmethodID g_Commit = nullptr;
+
+static EGLDisplay gDisplay = EGL_NO_DISPLAY;
+
+JNIEnv* GetJNIEnv() {
+ ssassert(g_JavaVM != NULL, "g_JavaVM is NULL");
+
+ JNIEnv* env;
+ int status = g_JavaVM->GetEnv((void**)&env, JNI_VERSION_1_6);
+ if (status == JNI_EDETACHED) {
+ ssassert(g_JavaVM->AttachCurrentThread(&env, nullptr) == JNI_OK, "Attach Failed");
+ } else {
+ char str[20];
+ sprintf(str, "GetEnv error: %d", status);
+ ssassert(status == JNI_OK, str);
+ }
+ return env;
+}
+
+// Opens a Storage Access Framework content URI through the Java activity's
+// ContentResolver. The Java side detaches the descriptor from its
+// ParcelFileDescriptor, so ownership passes to the caller (which must close
+// it, e.g. via fdopen + fclose). Returns -1 on failure.
+int AndroidOpenContentFile(const char *uri, const char *mode) {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity && g_OpenContentFileMethod)) return -1;
+
+ jstring juri = env->NewStringUTF(uri);
+ jstring jmode = env->NewStringUTF(mode);
+ jint fd = env->CallIntMethod(g_Activity, g_OpenContentFileMethod, juri, jmode);
+ env->DeleteLocalRef(juri);
+ env->DeleteLocalRef(jmode);
+ if (env->ExceptionCheck()) {
+ env->ExceptionDescribe();
+ env->ExceptionClear();
+ return -1;
+ }
+ return fd;
+}
+
+// Deletes a Storage Access Framework content URI through the Java activity.
+void AndroidDeleteContentFile(const char *uri) {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity && g_DeleteContentFileMethod)) return;
+
+ jstring juri = env->NewStringUTF(uri);
+ env->CallBooleanMethod(g_Activity, g_DeleteContentFileMethod, juri);
+ env->DeleteLocalRef(juri);
+ if (env->ExceptionCheck()) {
+ env->ExceptionClear();
+ }
+}
+
+// Returns the absolute path of the app's private files directory, or an empty
+// string if the Java activity is not available.
+std::string AndroidInternalStoragePath() {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity && g_GetInternalStoragePathMethod)) return "";
+
+ jstring ret = (jstring)env->CallObjectMethod(g_Activity, g_GetInternalStoragePathMethod);
+ if (env->ExceptionCheck()) {
+ env->ExceptionClear();
+ return "";
+ }
+ if (!ret) return "";
+ const char *str = env->GetStringUTFChars(ret, NULL);
+ std::string result = str ? str : "";
+ if (str) env->ReleaseStringUTFChars(ret, str);
+ env->DeleteLocalRef(ret);
+ return result;
+}
+
+// Maps a content URI to a filesystem-safe string, so it can be used as a
+// filename/key in private storage (e.g. for autosave companions).
+std::string AndroidContentKey(const char *uri) {
+ std::string key;
+ for (const char *p = uri; *p; ++p) {
+ unsigned char c = (unsigned char)*p;
+ if (isalnum(c) || c == '.' || c == '-' || c == '_') {
+ key += (char)c;
+ } else {
+ key += '_';
+ }
+ }
+ return key;
+}
+
+std::string DroidFileName(const char *uri) {
+ JNIEnv *env = GetJNIEnv();
+ if (!env || !g_Activity) {
+ return "";
+ }
+ jstring juri = env->NewStringUTF(uri);
+ jstring jstr = (jstring)env->CallObjectMethod(g_Activity, g_FileName, juri);
+ env->DeleteLocalRef(juri);
+ const char *str = env->GetStringUTFChars(jstr, NULL);
+ std::string ret = str ? str : "";
+ if (str) env->ReleaseStringUTFChars(jstr, str);
+ env->DeleteLocalRef(jstr);
+ return ret;
+}
+//-----------------------------------------------------------------------------
+// EGL and GL context management
+//-----------------------------------------------------------------------------
+
+class EGLContextManager {
+private:
+ EGLContext m_Context;
+ EGLSurface m_Surf = EGL_NO_SURFACE;
+ static EGLConfig m_Config;
+
+public:
+ ANativeWindow *m_NativeWindow;
+ EGLContextManager() :
+ m_Context(EGL_NO_CONTEXT),
+ m_Surf(EGL_NO_SURFACE),
+ m_NativeWindow(nullptr) {}
+
+ bool Initialize() {
+ if (gDisplay == EGL_NO_DISPLAY) {
+ // Get display
+ gDisplay = eglGetDisplay(EGL_DEFAULT_DISPLAY);
+ if (gDisplay == EGL_NO_DISPLAY) {
+ ALOGE("Failed to get EGL display");
+ return false;
+ }
+
+ // Initialize EGL
+ if (!eglInitialize(gDisplay, 0, 0)) {
+ ALOGE("Failed to initialize EGL");
+ return false;
+ }
+
+ // Choose config
+ const EGLint configAttribs[] = {
+ EGL_SURFACE_TYPE, EGL_WINDOW_BIT,
+ EGL_BLUE_SIZE, 8,
+ EGL_GREEN_SIZE, 8,
+ EGL_RED_SIZE, 8,
+ EGL_ALPHA_SIZE, 8,
+ EGL_DEPTH_SIZE, 24,
+ EGL_STENCIL_SIZE, 8,
+ EGL_RENDERABLE_TYPE, EGL_OPENGL_ES2_BIT,
+ EGL_NONE
+ };
+
+ EGLint numConfigs;
+ if (!eglChooseConfig(gDisplay, configAttribs, &m_Config, 1, &numConfigs)) {
+ ALOGE("Failed to choose EGL config");
+ return false;
+ }
+
+ EGLint format;
+ eglGetConfigAttrib(gDisplay, m_Config, EGL_NATIVE_VISUAL_ID, &format);
+ //NativeWindow_setBuffersGeometry(g_NativeWindow, 0, 0, format);
+ }
+
+ // Create context
+ const EGLint contextAttribs[] = {
+ EGL_CONTEXT_CLIENT_VERSION, 2,
+ EGL_NONE
+ };
+
+ m_Context = eglCreateContext(gDisplay, m_Config, EGL_NO_CONTEXT, contextAttribs);
+ dbp("CreateContext");
+ if (m_Context == EGL_NO_CONTEXT) {
+ ALOGE("Failed to create EGL context");
+ return false;
+ }
+
+ // Create surface
+ return CreateSurface();
+ }
+
+ bool CreateSurface() {
+ if (!m_NativeWindow) return false;
+ if (m_Surf == EGL_NO_SURFACE) {
+ m_Surf = eglCreateWindowSurface(gDisplay, m_Config, (EGLNativeWindowType)m_NativeWindow, NULL);
+ dbp("create surf %p", m_Surf);
+ } else dbp("surf %p", m_Surf);
+
+ // Make current
+ if (!eglMakeCurrent(gDisplay, m_Surf, m_Surf, m_Context)) {
+ ALOGE("Failed to make EGL context current");
+ return false;
+ }
+
+ return true;
+ }
+
+ void DestroySurface() {
+ if (gDisplay != EGL_NO_DISPLAY) {
+ eglMakeCurrent(gDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
+ if (m_Surf != EGL_NO_SURFACE) {
+ eglDestroySurface(gDisplay, m_Surf);
+ m_Surf = EGL_NO_SURFACE;
+ }
+ }
+ }
+
+ void Terminate() {
+ DestroySurface();
+ if (gDisplay != EGL_NO_DISPLAY) {
+ if (m_Context != EGL_NO_CONTEXT) {
+ eglDestroyContext(gDisplay, m_Context);
+ m_Context = EGL_NO_CONTEXT;
+ }
+ eglTerminate(gDisplay);
+ gDisplay = EGL_NO_DISPLAY;
+ }
+ ANativeWindow_release(m_NativeWindow);
+ }
+
+ __inline__ void SwapBuffers() {
+ if (gDisplay != EGL_NO_DISPLAY && m_Surf != EGL_NO_SURFACE) {
+ eglSwapBuffers(gDisplay, m_Surf);
+ }
+ }
+
+ __inline__ bool MakeCurrent() {
+ return eglMakeCurrent(gDisplay, m_Surf, m_Surf, m_Context);
+ }
+};
+
+EGLConfig EGLContextManager::m_Config = nullptr;
+
+//-----------------------------------------------------------------------------
+// Menus
+//-----------------------------------------------------------------------------
+class Attachable {
+public:
+ virtual void Attach(JNIEnv *env, jobject menu) = 0;
+};
+
+#define MITM_ENABLED 1
+#define MITM_CHECKABLE 2
+#define MITM_CHECKED 4
+class MenuItemImplAndroid final : public MenuItem, public Attachable {
+public:
+ static std::unordered_map item_map;
+ static jint nextId;
+ jint id, stat = MITM_ENABLED;
+ jchar alt = '\0';
+ jweak ref = nullptr;
+ std::string title;
+
+ ~MenuItemImplAndroid() {
+ if (item_map.find(id)!=item_map.end()) {
+ item_map.erase(id);
+ }
+ JNIEnv *env = GetJNIEnv();
+ if (env && ref) {
+ env->DeleteWeakGlobalRef(ref);
+ ref = nullptr;
+ }
+ }
+
+ void SetAccelerator(KeyboardEvent accel) override {
+ }
+
+ void SetIndicator(Indicator type) override {
+ switch(type) {
+ case Indicator::NONE:
+ stat &= ~MITM_CHECKABLE;
+ break;
+ case Indicator::RADIO_MARK:
+ case Indicator::CHECK_MARK:
+ stat |= MITM_CHECKABLE;
+ break;
+ }
+ }
+
+ void SetActive(bool active) override {
+ if (active) {
+ stat |= MITM_CHECKED;
+ } else {
+ stat &= ~MITM_CHECKED;
+ }
+ JNIEnv *env = GetJNIEnv();
+ if (env && ref && !env->IsSameObject(ref, NULL)) {
+ env->CallObjectMethod(ref, g_SetChecked, active);
+ }
+ }
+
+ void SetEnabled(bool enabled) override {
+ if(enabled) {
+ stat |= MITM_ENABLED;
+ } else {
+ stat &= ~MITM_ENABLED;
+ }
+ JNIEnv *env = GetJNIEnv();
+ if (env && ref && !env->IsSameObject(ref, NULL)) {
+ env->CallObjectMethod(ref, g_SetEnabled, enabled);
+ }
+ }
+
+ void Attach(JNIEnv *env, jobject menu) override {
+ jstring str = env->NewStringUTF(title.c_str());
+ jobject item = env->CallObjectMethod(menu, g_Add, 0, id, 0, str);
+ env->DeleteLocalRef(str);
+ if (alt) {
+ // TODO: verify whether the Alt key works
+ env->CallObjectMethod(item, g_SetAlphabeticShortcut, alt, AMETA_ALT_ON);
+ }
+ ref = env->NewWeakGlobalRef(item);
+ env->CallObjectMethod(item, g_SetCheckable, 0!=(stat&MITM_CHECKABLE));
+ env->CallObjectMethod(item, g_SetEnabled, 0!=(stat&MITM_ENABLED));
+ env->CallObjectMethod(item, g_SetChecked, 0!=(stat&MITM_CHECKED));
+ env->CallVoidMethod(item, g_SetShowAsAction, 1);
+ }
+
+ static TimerRef GetTimer() {
+ static TimerRef ref = CreateTimer();
+ return ref;
+ }
+};
+
+std::unordered_map MenuItemImplAndroid::item_map;
+jint MenuItemImplAndroid::nextId = 1;
+
+class MenuImplAndroid final : public Menu, public Attachable, public std::enable_shared_from_this {
+public:
+ std::vector> menus;
+ std::string title;
+ jchar alt = '\0';
+
+ MenuImplAndroid() {
+ }
+
+ MenuItemRef AddItem(const std::string &label,
+ std::function onTrigger = NULL,
+ bool mnemonics = true) override {
+ auto menuItem = std::make_shared();
+ menuItem->id = MenuItemImplAndroid::nextId++;
+ menuItem->onTrigger = onTrigger;
+ auto pAlt = label.find('&');
+ menuItem->title.assign(label);
+ if (pAlt != std::string::npos && pAlt+1alt = label[pAlt+1];
+ menuItem->title.erase(pAlt, 1);
+ }
+ MenuItemImplAndroid::item_map[menuItem->id] = menuItem.get();
+ menus.push_back(menuItem);
+ return menuItem;
+ }
+
+ MenuRef AddSubMenu(const std::string &label) override {
+ auto subMenu = std::make_shared();
+ subMenu->title.assign(label);
+ auto pAlt = label.find('&');
+ if (pAlt != std::string::npos && pAlt+1 < label.size()) {
+ subMenu->alt = label[pAlt+1];
+ subMenu->title.erase(pAlt, 1);
+ }
+ menus.push_back(subMenu);
+ return subMenu;
+ }
+
+ void AddSeparator() override {
+ // Android do not support separator
+ }
+
+ void PopUp() override {
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ MenuRef ref = shared_from_this();
+ MenuRef *pref = new MenuRef(std::move(ref));
+ env->CallVoidMethod(g_Activity, g_Popup, (jlong)pref);
+ }
+ }
+
+ void Clear() override {
+ menus.clear();
+ }
+
+ void Attach(JNIEnv *env, jobject jmenu) override {
+ jstring str = env->NewStringUTF(title.c_str());
+ jobject subMenu = env->CallObjectMethod(jmenu, g_AddSubMenu, str);
+ env->DeleteLocalRef(str);
+ for (const auto& m : menus) {
+ m->Attach(env, subMenu);
+ }
+ }
+};
+
+class MenuBarImplAndroid final : public MenuBar {
+public:
+ std::vector> subMenus;
+
+ MenuRef AddSubMenu(const std::string &label) override {
+ auto subMenu = std::make_shared();
+ auto pAlt = label.find('&');
+ subMenu->title.assign(label);
+ if (pAlt != std::string::npos && pAlt+1alt = label[pAlt+1];
+ subMenu->title.erase(pAlt, 1);
+ }
+ subMenus.push_back(subMenu);
+
+ return subMenu;
+ }
+
+ void Clear() override {
+ subMenus.clear();
+ }
+};
+
+MenuRef CreateMenu() {
+ return std::make_shared();
+}
+
+MenuBarRef GetOrCreateMainMenu(bool *unique) {
+ static std::shared_ptr mainMenu;
+ if(!mainMenu) {
+ mainMenu = std::make_shared();
+ }
+ *unique = true;
+ return mainMenu;
+}
+
+//-----------------------------------------------------------------------------
+// Android Window Implementation
+//-----------------------------------------------------------------------------
+// Global window map (Android typically has one main window)
+static int g_NextWindowId = 0;
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId);
+class WindowImplAndroid final : public Window {
+private:
+ bool m_Visible;
+ bool m_FullScreen;
+ std::string m_Title;
+ MenuBarRef m_MenuBar;
+ float m_ScrollMin = 0.0;
+ float m_ScrollMax = 1.0;
+ float m_ScrollPage = 0.0;
+ float m_ScrollPos = 0.0;
+ bool m_ScrollVis = false;
+ TimerRef m_Inval;
+
+public:
+ int id;
+ EGLContextManager m_EGLContext;
+
+ WindowImplAndroid(Window::Kind kind) :
+ m_Visible(false),
+ m_FullScreen(false) {
+ id = g_NextWindowId;
+ // Android typically has only one window (the activity)
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ jobject jsurf = env->CallObjectMethod(g_Activity, g_OnWinAdded, id);
+ ANativeWindow *navWin = ANativeWindow_fromSurface(env, jsurf);
+ ANativeWindow_acquire(navWin);
+ m_EGLContext.m_NativeWindow = navWin;
+ m_EGLContext.Initialize();
+ }
+ g_NextWindowId++;
+ }
+
+ ~WindowImplAndroid() override {
+ m_EGLContext.Terminate();
+ }
+
+ void CreateSurface() {
+ m_EGLContext.CreateSurface();
+ }
+
+ // Window interface implementation
+ double GetDevicePixelRatio() override {
+ // Get density from Android display metrics
+ JNIEnv* env = GetJNIEnv();
+ if (!env || !g_Activity) return 1.0;
+
+ return env->CallFloatMethod(g_Activity, g_GetDensity);
+ }
+
+ double GetPixelDensity() override {
+ return GetDevicePixelRatio() * 160;
+ }
+
+ bool IsVisible() override {
+ return m_Visible;
+ }
+
+ void SetVisible(bool visible) override {
+ m_Visible = visible;
+ // Visibility is controlled by Android activity lifecycle
+ }
+
+ void Focus() override {
+ // Android windows are always focused when visible
+ }
+
+ bool IsFullScreen() override {
+ return m_FullScreen;
+ }
+
+ void SetFullScreen(bool fullScreen) override {
+ m_FullScreen = fullScreen;
+ // Request fullscreen through JNI
+ JNIEnv* env = GetJNIEnv();
+ if (env && g_Activity) {
+ jclass activityClass = env->GetObjectClass(g_Activity);
+ jmethodID setFullscreenMethod = env->GetMethodID(activityClass, "setFullScreen", "(Z)V");
+ if (setFullscreenMethod) {
+ env->CallVoidMethod(g_Activity, setFullscreenMethod, fullScreen);
+ }
+ }
+ }
+
+ void SetTitle(const std::string &title) override {
+ m_Title = title;
+ if (id != 0) return;
+ JNIEnv* env = GetJNIEnv();
+ if (env && g_Activity) {
+ jstring jTitle = env->NewStringUTF(title.c_str());
+ env->CallVoidMethod(g_Activity, g_SetTitle, jTitle);
+ env->DeleteLocalRef(jTitle);
+ }
+ }
+
+ void SetMenuBar(MenuBarRef newMenuBar) override {
+ m_MenuBar = newMenuBar;
+ if (!m_Visible || id != 0)
+ return;
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ env->CallVoidMethod(g_Activity, g_InvalidateMenu);
+ }
+ }
+
+ void GetContentSize(double *width, double *height) override {
+ float pixelRatio = GetDevicePixelRatio();
+ *width = ANativeWindow_getWidth(m_EGLContext.m_NativeWindow) / pixelRatio;
+ *height = ANativeWindow_getHeight(m_EGLContext.m_NativeWindow) / pixelRatio;
+ }
+
+ void SetMinContentSize(double width, double height) override {
+ // Minimum size is handled by Android
+ // dbp("minsize %lf %lf", width, height);
+ }
+
+ void FreezePosition(SettingsRef settings, const std::string &key) override {
+ // Window position is managed by Android
+ }
+
+ void ThawPosition(SettingsRef settings, const std::string &key) override {
+ // Window position is managed by Android
+ }
+
+ void SetCursor(Cursor cursor) override {
+ // Cursors are not typically used on Android touch devices
+ }
+
+ void SetTooltip(const std::string &text, double x, double y,
+ double width, double height) override {
+ // Tooltips are not commonly used in Android apps
+ }
+
+ bool IsEditorVisible() override {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity)) return false;
+ return env->CallBooleanMethod(g_Activity, g_IsEditorVisibleMethod);
+ }
+
+ void ShowEditor(double x, double y, double fontHeight, double minWidth,
+ bool isMonospace, const std::string &text) override {
+ // Show software keyboard through JNI
+ JNIEnv* env = GetJNIEnv();
+ if (!(env && g_Activity)) return;
+ jstring jText = env->NewStringUTF(text.c_str());
+ env->CallVoidMethod(g_Activity, g_ShowEditorMethod,
+ id, (float)x, (float)y, (float)fontHeight, (float)minWidth, isMonospace, jText);
+ env->DeleteLocalRef(jText);
+ }
+
+ void HideEditor() override {
+ // Hide software keyboard through JNI
+ JNIEnv* env = GetJNIEnv();
+ if (!(env && g_Activity)) return;
+ env->CallVoidMethod(g_Activity, g_HideEditorMethod);
+ }
+
+ void SetScrollbarVisible(bool visible) override {
+ m_ScrollVis = visible;
+ UpdateScrollbar();
+ }
+
+ void ConfigureScrollbar(double min, double max, double pageSize) override {
+ m_ScrollMin = min;
+ m_ScrollMax = max;
+ m_ScrollPage = pageSize;
+ UpdateScrollbar();
+ }
+
+ double GetScrollbarPosition() override {
+ return m_ScrollPos;
+ }
+
+ void SetScrollbarPosition(double pos) override {
+ m_ScrollPos = pos;
+ UpdateScrollbar();
+ }
+
+ // Pushes the current scrollbar geometry to the Java layer, which draws the
+ // thumb as the SurfaceView foreground (a LayerDrawable item pinned right).
+ void UpdateScrollbar() {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity) || !g_SetScrollbar) return;
+ env->CallVoidMethod(g_Activity, g_SetScrollbar,
+ id, m_ScrollMin, m_ScrollMax, m_ScrollPage,
+ m_ScrollPos, m_ScrollVis);
+ }
+
+ void Render() {
+ if (onRender) {
+ m_EGLContext.MakeCurrent();
+ onRender();
+ m_EGLContext.SwapBuffers();
+ }
+ }
+
+ void Invalidate() override {
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ if (!m_Inval) {
+ m_Inval = CreateTimer();
+ m_Inval->onTimeout = std::bind(&WindowImplAndroid::Render, this);
+ }
+ m_Inval->RunAfterNextFrame();
+ }
+ }
+
+ __inline__ void InvalidateMenu(JNIEnv *env, jobject jmenu) {
+ if (!m_MenuBar) return;
+ auto menuBar = (MenuBarImplAndroid*)m_MenuBar.get();
+ for (auto &menu : menuBar->subMenus) {
+ menu->Attach(env, jmenu);
+ }
+ }
+
+ __inline__ bool MakeCurrent() {
+ UpdateScrollbar();
+ return m_EGLContext.MakeCurrent();
+ }
+};
+static std::unordered_map> g_Windows;
+
+//-----------------------------------------------------------------------------
+// Window creation
+//-----------------------------------------------------------------------------
+
+WindowRef CreateWindow(Window::Kind kind, WindowRef parentWindow) {
+ auto window = std::make_shared(kind);
+ g_Windows[window->id] = window;
+ return window;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject jamgr, jobjectArray strs) {
+ g_Activity = env->NewGlobalRef(thiz);
+ amgr = AAssetManager_fromJava(env, jamgr);
+ jsize siz = 0, i = 0;
+ if (strs && (siz=env->GetArrayLength(strs))) {
+ for (i=0; iGetObjectArrayElement(strs, i);
+ const char *chars = env->GetStringUTFChars(str, nullptr);
+ if (chars != nullptr) {
+ if (SetLocale(chars)) {
+ i = siz; // after this loop, i = siz+1
+ }
+ env->ReleaseStringUTFChars(str, chars);
+ }
+ env->DeleteLocalRef(str);
+ }
+ }
+ if (i <= siz) {
+ SetLocale("en_US");
+ }
+ SS.Init();
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeClear(JNIEnv* env, jobject thiz) {
+ SS.Clear();
+ SK.Clear();
+ g_Windows.clear();
+ g_NextWindowId = 0;
+ MenuItemImplAndroid::nextId = 1;
+ MenuItemImplAndroid::item_map.clear();
+ env->DeleteGlobalRef(g_Activity);
+ g_Activity = nullptr;
+ amgr = nullptr;
+}
+
+void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId) {
+ if (winId < g_NextWindowId) {
+ g_Windows[winId]->Invalidate();
+ }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeSetSurface(JNIEnv *env, jclass jc, jint wid, jobject surf) {
+ auto win = g_Windows[wid].get();
+ auto eglCont = &win->m_EGLContext;
+ if (eglCont->m_NativeWindow) {
+ ANativeWindow_release(eglCont->m_NativeWindow);
+ }
+ eglCont->m_NativeWindow = ANativeWindow_fromSurface(env, surf);
+ ANativeWindow_acquire(eglCont->m_NativeWindow);
+ eglCont->DestroySurface();
+ eglCont->CreateSurface();
+ win->UpdateScrollbar();
+ win->Invalidate();
+}
+
+static time_t lastDown = 0;
+static double lastDist = 0.;
+static bool doubleClicked = false;
+#define DBL_TIMEOUT 300L
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass jc, jint wid, jint act, jfloat x, jfloat y, jdouble dist, jint button, jint state) {
+ auto win = g_Windows[wid];
+ MouseEvent event = {.x = x, .y = y};
+ event.shiftDown = state & AMETA_SHIFT_ON;
+ event.controlDown = state & AMETA_CTRL_ON;
+ if (act != AMOTION_EVENT_ACTION_MOVE)
+ doubleClicked = false;
+ switch (act) {
+ case AMOTION_EVENT_ACTION_DOWN: {
+ struct timespec ts;
+ clock_gettime(CLOCK_BOOTTIME, &ts);
+ time_t tm = ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
+ if (tm - lastDown < DBL_TIMEOUT) {
+ event.type = MouseEvent::Type::DBL_PRESS;
+ doubleClicked = true;
+ } else {
+ event.type = MouseEvent::Type::PRESS;
+ }
+ lastDown = tm;
+ lastDist = dist;
+ break;
+ }
+ case AMOTION_EVENT_ACTION_POINTER_DOWN:
+ lastDist = dist;
+ event.type = MouseEvent::Type::PRESS;
+ break;
+ case AMOTION_EVENT_ACTION_MOVE:
+ if (lastDist != 0. && dist != 0.) {
+ event.type = MouseEvent::Type::SCROLL_VERT;
+ event.scrollDelta = fmax(-2.0, fmin((dist - lastDist) / 25.0, 2.0));
+ lastDist = dist;
+ auto fun = win->onRender;
+ win->onRender = nullptr;
+ win->onMouseEvent(event);
+ win->onRender = fun;
+ }
+ event.type = MouseEvent::Type::MOTION;
+ // map doubleClicked move to middle clicked move for orbiting
+ if (doubleClicked)
+ button = AMOTION_EVENT_BUTTON_TERTIARY;
+ break;
+ case AMOTION_EVENT_ACTION_UP:
+ case AMOTION_EVENT_ACTION_POINTER_UP:
+ event.type = MouseEvent::Type::RELEASE;
+ lastDist = 0.;
+ break;
+ case AMOTION_EVENT_ACTION_SCROLL:
+ event.type = MouseEvent::Type::SCROLL_VERT;
+ event.scrollDelta = dist;
+ break;
+ case AMOTION_EVENT_ACTION_CANCEL:
+ case AMOTION_EVENT_ACTION_OUTSIDE:
+ event.type = MouseEvent::Type::LEAVE;
+ lastDist = 0.;
+ break;
+ }
+ switch (button) {
+ case AMOTION_EVENT_BUTTON_PRIMARY:
+ event.button = MouseEvent::Button::LEFT;
+ break;
+ case AMOTION_EVENT_BUTTON_SECONDARY:
+ event.button = MouseEvent::Button::RIGHT;
+ break;
+ case AMOTION_EVENT_BUTTON_TERTIARY:
+ event.button = MouseEvent::Button::MIDDLE;
+ break;
+ default:
+ event.button = MouseEvent::Button::NONE;
+ }
+ return win->onMouseEvent(event);
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnKeyEvent(JNIEnv *env, jclass jc, jint wid, jint keyact, jint keyenc, jint metastate) {
+ if (keyact == 0)
+ return false; // key char from getUnicodeChar() would failed sometimes and trigger the command::none
+ auto win = g_Windows[wid];
+ KeyboardEvent event = {};
+ if (keyact == AKEY_EVENT_ACTION_DOWN)
+ event.type = KeyboardEvent::Type::PRESS;
+ else if (keyact == AKEY_EVENT_ACTION_UP)
+ event.type = KeyboardEvent::Type::RELEASE;
+ if (keyenc < 0) {
+ event.key = KeyboardEvent::Key::FUNCTION;
+ event.num = -keyenc - AKEYCODE_F1 + 1;
+ } else {
+ event.key = KeyboardEvent::Key::CHARACTER;
+ event.chr = keyenc;
+ }
+ event.shiftDown = metastate & AMETA_SHIFT_ON;
+ event.controlDown = metastate & AMETA_CTRL_ON;
+ return win->onKeyboardEvent(event);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnEditorDone(JNIEnv *env, jclass clz, jint wid, jstring str) {
+ auto win = g_Windows[wid];
+ const char * cstr = env->GetStringUTFChars(str, NULL);
+ win->onEditingDone(cstr);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnScrollbarAdjusted(JNIEnv *env, jobject thiz, jint wid, jdouble pos) {
+ auto win = g_Windows[wid];
+ auto adjust = win->onScrollbarAdjusted;
+ if (adjust) {
+ adjust(pos);
+ }
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_solvespace_SolveSpaceActivity_onCreateOptionsMenu(JNIEnv *env, jobject thiz, jobject menu) {
+ if (g_NextWindowId == 0) return false;
+ env->CallVoidMethod(menu, g_Clear);
+ g_Windows[0]->InvalidateMenu(env, menu);
+ return true;
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_solvespace_SolveSpaceActivity_onOptionsItemSelected(JNIEnv *env, jobject thiz, jobject jitem) {
+ jint id = env->CallIntMethod(jitem, g_GetItemId);
+ auto pfunc = MenuItemImplAndroid::item_map.find(id);
+ if (pfunc == MenuItemImplAndroid::item_map.end()) {
+ return false;
+ }
+ auto item = pfunc->second;
+ if (item->onTrigger){
+ auto timer = MenuItemImplAndroid::GetTimer();
+ timer->onTimeout = item->onTrigger;
+ timer->RunAfter(0);
+ return true;
+ }
+ return false;
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnCreateContextMenu(JNIEnv *env, jclass clz, jobject jmenu, std::shared_ptr *menu) {
+ if (menu && menu->get())
+ for (auto m:menu->get()->menus)
+ m->Attach(env, jmenu);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_SolveSpaceActivity_nativeOnContextMenuClosed(JNIEnv *env, jclass, std::shared_ptr *pref) {
+ SS.GW.context.active = false;
+ SS.ScheduleShowTW();
+ delete pref;
+}
+
+//-----------------------------------------------------------------------------
+// JNI initialization
+//-----------------------------------------------------------------------------
+
+extern "C" JNIEXPORT jint JNICALL
+JNI_OnLoad(JavaVM* vm, void* reserved) {
+ g_JavaVM = vm;
+
+ JNIEnv* env;
+ if (vm->GetEnv(reinterpret_cast(&env), JNI_VERSION_1_6) != JNI_OK) {
+ return -1;
+ }
+
+ // Find our activity class
+ jclass activityClass = env->FindClass("com/solvespace/SolveSpaceActivity");
+ if (!activityClass) {
+ ALOGE("Failed to find activity class");
+ return -1;
+ }
+
+ // Cache method IDs
+ g_IsEditorVisibleMethod = env->GetMethodID(activityClass, "isEditorVisible", "()Z");
+ g_ShowEditorMethod = env->GetMethodID(activityClass, "showEditor",
+ "(IFFFFZLjava/lang/String;)V");
+ g_HideEditorMethod = env->GetMethodID(activityClass, "hideEditor", "()V");
+ g_ShowMessageDialogMethod = env->GetMethodID(activityClass, "showMessageDialog",
+ "(Ljava/lang/String;Ljava/lang/String;I)I");
+ g_ShowFileDialogMethod = env->GetMethodID(activityClass, "showFileDialog",
+ "(ZLjava/lang/String;[Ljava/lang/String;)Ljava/lang/String;");
+ g_SendDelayedMethod = env->GetMethodID(activityClass, "sendDelayed",
+ "(JJ)V");
+ g_SetTitle = env->GetMethodID(activityClass, "setWinTitle",
+ "(Ljava/lang/String;)V");
+ g_OnWinAdded = env->GetMethodID(activityClass, "onWinAdded", "(I)Landroid/view/Surface;");
+ g_GetPreferences = env->GetMethodID(activityClass, "getPreferences", "(I)Landroid/content/SharedPreferences;");
+ g_GetDensity = env->GetMethodID(activityClass, "getDensity", "()F");
+ g_SetScrollbar = env->GetMethodID(activityClass, "setScrollbar", "(IFFFFZ)V");
+ g_Popup = env->GetMethodID(activityClass, "popup", "(J)V");
+ g_OpenContentFileMethod = env->GetMethodID(activityClass, "openContentFile",
+ "(Ljava/lang/String;Ljava/lang/String;)I");
+ g_DeleteContentFileMethod = env->GetMethodID(activityClass, "deleteContentFile",
+ "(Ljava/lang/String;)Z");
+ g_GetInternalStoragePathMethod = env->GetMethodID(activityClass, "getInternalStoragePath",
+ "()Ljava/lang/String;");
+ g_Finish = env->GetMethodID(activityClass, "finish", "()V");
+ g_GetSysFonts = env->GetStaticMethodID(activityClass, "getSysFonts", "()[Ljava/lang/String;");
+ g_InvalidateMenu = env->GetMethodID(activityClass, "invalidateOptionsMenu", "()V");
+ g_FileName = env->GetMethodID(activityClass, "fileName", "(Ljava/lang/String;)Ljava/lang/String;");
+
+ jclass menuClz = env->FindClass("android/view/Menu");
+ ssassert(menuClz != NULL, "Failed to find Menu class");
+ g_Add = env->GetMethodID(menuClz, "add", "(IIILjava/lang/CharSequence;)Landroid/view/MenuItem;");
+ g_AddSubMenu = env->GetMethodID(menuClz, "addSubMenu", "(Ljava/lang/CharSequence;)Landroid/view/SubMenu;");
+ g_Clear = env->GetMethodID(menuClz, "clear", "()V");
+ jclass itemClz = env->FindClass("android/view/MenuItem");
+ ssassert(itemClz != NULL, "Failed to find MenuItem class");
+ g_GetItemId = env->GetMethodID(itemClz, "getItemId", "()I");
+ jmethodID setAlphaShort = env->GetMethodID(itemClz, "setAlphabeticShortcut", "(CI)Landroid/view/MenuItem;");
+ if (setAlphaShort == nullptr) {
+ setAlphaShort = env->GetMethodID(itemClz, "setAlphabeticShortcut", "(C)Landroid/view/MenuItem;");
+ }
+ g_SetAlphabeticShortcut = setAlphaShort;
+ g_SetCheckable = env->GetMethodID(itemClz, "setCheckable", "(Z)Landroid/view/MenuItem;");
+ g_SetChecked = env->GetMethodID(itemClz, "setChecked", "(Z)Landroid/view/MenuItem;");
+ g_SetEnabled = env->GetMethodID(itemClz, "setEnabled", "(Z)Landroid/view/MenuItem;");
+ g_SetShowAsAction = env->GetMethodID(itemClz, "setShowAsAction", "(I)V");
+ jclass prefClass = env->FindClass("android/content/SharedPreferences");
+ ssassert(prefClass != NULL , "Failed to find SharedPreferences class");
+#define BL "("
+#define BR ")"
+#define JSTR "Ljava/lang/String;"
+#define EDIT "Landroid/content/SharedPreferences$Editor;"
+ g_GetInt = env->GetMethodID(prefClass, "getInt", BL JSTR "I" BR "I");
+ g_GetFloat = env->GetMethodID(prefClass, "getFloat", BL JSTR "F" BR "F");
+ g_GetString = env->GetMethodID(prefClass, "getString", BL JSTR JSTR BR JSTR);
+ g_Edit = env->GetMethodID(prefClass, "edit", "()" EDIT);
+ jclass editClass = env->FindClass("android/content/SharedPreferences$Editor");
+ ssassert(editClass != NULL, "Failed to find SharedPrefereces$Editor class");
+
+ g_PutInt = env->GetMethodID(editClass, "putInt", BL JSTR "I" BR EDIT);
+ g_PutFloat = env->GetMethodID(editClass, "putFloat", BL JSTR "F" BR EDIT);
+ g_PutString = env->GetMethodID(editClass, "putString", BL JSTR JSTR BR EDIT);
+ g_Commit = env->GetMethodID(editClass, "commit", "()Z");
+#undef BL
+#undef BR
+#undef JSTR
+#undef EDIT
+ return JNI_VERSION_1_6;
+}
+
+//-----------------------------------------------------------------------------
+// Android-specific implementations of other platform functions
+//-----------------------------------------------------------------------------
+
+// Settings, Timers, Menus, Dialogs etc. would need similar Android implementations
+// For brevity, I'm showing the core window implementation
+
+class SettingsImplAndroid final : public Settings {
+public:
+ jobject prefs;
+
+ SettingsImplAndroid() {
+ JNIEnv *env = GetJNIEnv();
+ prefs = env->NewGlobalRef(env->CallObjectMethod(g_Activity, g_GetPreferences, 0));
+ }
+
+ ~SettingsImplAndroid() {
+ JNIEnv *env = GetJNIEnv();
+ if (env) {
+ env->DeleteGlobalRef(prefs);
+ }
+ }
+
+ void FreezeInt(const std::string &key, uint32_t value) override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return;
+ jobject edit = env->CallObjectMethod(prefs, g_Edit);
+ jstring jkey = env->NewStringUTF(key.c_str());
+ env->CallObjectMethod(edit, g_PutInt, jkey, value);
+ env->CallBooleanMethod(edit, g_Commit);
+ env->DeleteLocalRef(jkey);
+ }
+
+ uint32_t ThawInt(const std::string &key, uint32_t defaultValue) override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return defaultValue;
+ jstring jkey = env->NewStringUTF(key.c_str());
+ uint32_t t = env->CallIntMethod(prefs, g_GetInt, jkey, defaultValue);
+ env->DeleteLocalRef(jkey);
+ return t;
+ }
+
+ void FreezeFloat(const std::string &key, double value) override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return;
+ jobject edit = env->CallObjectMethod(prefs, g_Edit);
+ jstring jkey = env->NewStringUTF(key.c_str());
+ env->CallObjectMethod(edit, g_PutFloat, jkey, (float)value);
+ env->CallBooleanMethod(edit, g_Commit);
+ env->DeleteLocalRef(jkey);
+ }
+
+ double ThawFloat(const std::string &key, double defaultValue) override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return defaultValue;
+ jstring jkey = env->NewStringUTF(key.c_str());
+ double t = env->CallFloatMethod(prefs, g_GetFloat, jkey, (float)defaultValue);
+ env->DeleteLocalRef(jkey);
+ return t;
+ }
+
+ void FreezeString(const std::string &key, const std::string &value) override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return;
+ jobject edit = env->CallObjectMethod(prefs, g_Edit);
+ jstring jkey = env->NewStringUTF(key.c_str());
+ jstring jval = env->NewStringUTF(value.c_str());
+ env->CallObjectMethod(edit, g_PutString, jkey, jval);
+ env->CallBooleanMethod(edit, g_Commit);
+ env->DeleteLocalRef(jkey);
+ env->DeleteLocalRef(jval);
+ }
+
+ std::string ThawString(const std::string &key, const std::string &defaultValue = "") override {
+ JNIEnv *env = GetJNIEnv();
+ if (!env) return defaultValue;
+ jstring jkey = env->NewStringUTF(key.c_str());
+ jstring jdval = env->NewStringUTF(defaultValue.c_str());
+ jstring jret = (jstring)env->CallObjectMethod(prefs, g_GetString, jkey, jdval);
+ env->DeleteLocalRef(jkey);
+ env->DeleteLocalRef(jdval);
+ return env->GetStringUTFChars(jret, NULL);
+ }
+};
+
+SettingsRef GetSettings() {
+ static std::shared_ptr settings;
+ if(!settings) {
+ settings = std::make_shared();
+ }
+ return settings;
+}
+
+class TimerImplAndroid final : public Timer {
+private:
+ void post(jlong milliseconds) {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity)) {
+ return;
+ }
+ env->CallVoidMethod(g_Activity, g_SendDelayedMethod, this, milliseconds);
+ }
+public:
+ void RunAfter(unsigned int milliseconds) override {
+ post(milliseconds);
+ }
+ void RunAfterNextFrame() override {
+ post(-1L);
+ }
+};
+
+TimerRef CreateTimer() {
+ return std::make_shared();
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_solvespace_MainHandler_nativeRun(JNIEnv* env, jclass clazz, Timer *timer) {
+ auto func = timer->onTimeout;
+ if (func) func();
+}
+
+//-----------------------------------------------------------------------------
+// 3DConnexion support
+//-----------------------------------------------------------------------------
+
+void Open3DConnexion() {}
+void Close3DConnexion() {}
+void Request3DConnexionEventsForWindow(WindowRef window) {}
+
+#define BTN_NO 1
+#define BTN_YES 2
+#define BTN_CANCEL 4
+#define BTN_OK 8
+//-----------------------------------------------------------------------------
+// Message dialogs
+//-----------------------------------------------------------------------------
+class MessageDialogImplAndroid final : public MessageDialog {
+public:
+ std::string tit;
+ std::string msg;
+ int buttons = 0;
+
+ MessageDialogImplAndroid() {
+ SetTitle("Message");
+ }
+
+ void SetType(Type type) override {
+ }
+
+ void SetTitle(std::string title) override {
+ tit = title;
+ }
+
+ void SetMessage(std::string message) override {
+ msg = message;
+ }
+
+ void SetDescription(std::string description) override {
+ }
+
+ void AddButton(std::string _label, Response response, bool isDefault) override {
+ int btn = 0;
+ switch (response) {
+ case Response::OK: btn = BTN_OK; break;
+ case Response::YES: btn = BTN_YES; break;
+ case Response::NO: btn = BTN_NO; break;
+ case Response::CANCEL: btn = BTN_CANCEL; break;
+ default: ssassert(false, "Invalid response"); break;
+ }
+ buttons |= btn;
+ }
+
+ Response RunModal() override {
+ char buf[20];
+ sprintf(buf, "Err btns %d", buttons);
+ ssassert(buttons == BTN_OK
+ || buttons == (BTN_OK|BTN_CANCEL)
+ || buttons == (BTN_YES|BTN_NO)
+ || buttons == (BTN_CANCEL|BTN_YES|BTN_NO),
+ buf);
+ auto env = GetJNIEnv();
+ ssassert(env != NULL, "GetEnv Failed");
+ auto jtit = env->NewStringUTF(tit.c_str());
+ auto jmsg = env->NewStringUTF(msg.c_str());
+ int ret = env->CallIntMethod(g_Activity, g_ShowMessageDialogMethod, jtit, jmsg, buttons);
+ env->DeleteLocalRef(jtit);
+ env->DeleteLocalRef(jmsg);
+ switch (ret) {
+ case BTN_OK: return Response::OK;
+ case BTN_YES: return Response::YES;
+ case BTN_NO: return Response::NO;
+ case BTN_CANCEL: return Response::CANCEL;
+ default: ssassert(false, "Invalid response");break;
+ }
+ }
+};
+
+MessageDialogRef CreateMessageDialog(WindowRef parentWindow) {
+ return std::make_shared();
+}
+
+//-----------------------------------------------------------------------------
+// File dialogs
+//-----------------------------------------------------------------------------
+class FileDialogImplAndroid final : public FileDialog {
+public:
+
+ jboolean isSaveDialog;
+ std::string uri;
+ std::string sugName;
+ std::vector exts;
+
+ FileDialogImplAndroid(bool isSaveDialog) {
+ this->isSaveDialog = isSaveDialog;
+ }
+
+ void SetTitle(std::string title) override {
+ }
+
+ void SetCurrentName(std::string name) override {
+ sugName = name;
+ }
+
+ void SetFilename(Path path) override {
+ uri = path.raw;
+ sugName = path.FileName();
+ }
+
+ Path GetFilename() override {
+ return Path::From(uri);
+ }
+
+ void SuggestFilename(Platform::Path path) override {
+ sugName = path.FileName();
+ }
+
+ void AddFilter(std::string name, std::vector extensions) override {
+ exts.insert(exts.end(), extensions.begin(), extensions.end());
+ }
+
+ void FreezeChoices(SettingsRef settings, const std::string &key) override {
+ }
+
+ void ThawChoices(SettingsRef settings, const std::string &key) override {
+ }
+
+ bool RunModal() override {
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ jstring tit = nullptr;
+ if (isSaveDialog)
+ tit = env->NewStringUTF(sugName.empty() ? _("untitled") : sugName.c_str());
+ jclass strClz = env->FindClass("java/lang/String");
+ jsize siz = (jsize)exts.size();
+ jobjectArray strArr = env->NewObjectArray(siz, strClz, nullptr);
+ for (jsize i=0; iNewStringUTF(exts[i].c_str());
+ env->SetObjectArrayElement(strArr, i, jstr);
+ env->DeleteLocalRef(jstr);
+ }
+ jstring ret = (jstring)env->CallObjectMethod(g_Activity, g_ShowFileDialogMethod, isSaveDialog, tit, strArr);
+ if (tit) env->DeleteLocalRef(tit);
+ env->DeleteLocalRef(strArr);
+ if (ret) {
+ uri = env->GetStringUTFChars(ret, nullptr);
+ return true;
+ }
+ }
+ return false;
+ }
+};
+
+FileDialogRef CreateOpenFileDialog(WindowRef parentWindow) {
+ return std::make_shared(false);
+}
+
+FileDialogRef CreateSaveFileDialog(WindowRef parentWindow) {
+ return std::make_shared(true);
+}
+
+//-----------------------------------------------------------------------------
+// Application-wide APIs
+//-----------------------------------------------------------------------------
+
+std::vector GetFontFiles() {
+ std::vector fonts;
+ JNIEnv *env = GetJNIEnv();
+ if (!env || !g_Activity) {
+ return fonts;
+ }
+ jobjectArray jfonts = (jobjectArray)env->CallStaticObjectMethod(env->GetObjectClass(g_Activity), g_GetSysFonts);
+ jsize length = env->GetArrayLength(jfonts);
+ if (length <= 0) {
+ return fonts;
+ }
+ fonts.reserve(length);
+ for (jsize i = 0; i < length; ++i) {
+ jstring jStr = (jstring)env->GetObjectArrayElement(jfonts, i);
+
+ const char* utfChars = env->GetStringUTFChars(jStr, nullptr);
+ if (utfChars != nullptr) {
+ fonts.emplace_back(Path::From(utfChars));
+ env->ReleaseStringUTFChars(jStr, utfChars);
+ }
+
+ env->DeleteLocalRef(jStr);
+ }
+
+ return fonts;
+}
+
+void OpenInBrowser(const std::string &url) {
+ JNIEnv *env = GetJNIEnv();
+ if (!(env && g_Activity)) return;
+
+ jmethodID openUrl = env->GetMethodID(env->GetObjectClass(g_Activity), "openUrl", "(Ljava/lang/String;)V");
+ if (openUrl) {
+ jstring jurl = env->NewStringUTF(url.c_str());
+ env->CallVoidMethod(g_Activity, openUrl, jurl);
+ env->DeleteLocalRef(jurl);
+ }
+}
+
+void ExitGui() {
+ JNIEnv *env = GetJNIEnv();
+ if (env && g_Activity) {
+ env->CallVoidMethod(g_Activity, g_Finish);
+ }
+}
+
+} // namespace Platform
+} // namespace SolveSpace
diff --git a/src/platform/java/com/solvespace/MainHandler.java b/src/platform/java/com/solvespace/MainHandler.java
new file mode 100644
index 000000000..0e32cb154
--- /dev/null
+++ b/src/platform/java/com/solvespace/MainHandler.java
@@ -0,0 +1,17 @@
+package com.solvespace;
+import android.os.*;
+
+public class MainHandler extends Handler
+{
+
+ public static final int HD_NRUN = 1, HD_RET = 2;
+ @Override
+ public void handleMessage(Message msg) {
+ if (msg.what == HD_NRUN)
+ nativeRun((Long)msg.obj);
+ else if (msg.what == HD_RET)
+ throw new RuntimeException();
+ }
+
+ public final static native void nativeRun(long obj);
+}
diff --git a/src/platform/java/com/solvespace/SolveSpaceActivity.java b/src/platform/java/com/solvespace/SolveSpaceActivity.java
new file mode 100644
index 000000000..32c2f139c
--- /dev/null
+++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java
@@ -0,0 +1,718 @@
+package com.solvespace;
+
+import android.app.*;
+import android.content.*;
+import android.content.res.*;
+import android.database.*;
+import android.graphics.*;
+import android.graphics.drawable.*;
+import android.graphics.drawable.shapes.*;
+import android.graphics.fonts.*;
+import android.net.*;
+import android.os.*;
+import android.provider.DocumentsContract;
+import android.text.*;
+import android.util.*;
+import android.view.*;
+import android.view.inputmethod.*;
+import android.widget.*;
+import java.io.*;
+import java.util.*;
+
+public class SolveSpaceActivity extends Activity
+implements SurfaceHolder.Callback2, ActionBar.OnNavigationListener,
+View.OnTouchListener, View.OnGenericMotionListener, TextView.OnEditorActionListener,
+DialogInterface.OnCancelListener, DialogInterface.OnClickListener,
+PopupWindow.OnDismissListener, DialogInterface.OnDismissListener, View.OnClickListener
+{
+ private final static String TAG = "SolveSpace";
+ private final static int
+ BTN_NO = 1,
+ BTN_YES = 2,
+ BTN_CANCEL = 4,
+ BTN_OK = 8;
+ static {
+ System.loadLibrary("solvespace");
+ }
+
+ private Handler hand = new MainHandler();
+ private SurfaceView[] svs;
+ private PopupWindow pw;
+ private long pmenu;
+ private int inited;
+
+ // Vertical scrollbar (rendered as the SurfaceView foreground).
+ private int mScrollbarWidth = 8;
+ // True while the user is actively dragging the scrollbar thumb.
+ private boolean mScrollDragging;
+
+ // Mouse states
+ private float currX, currY;
+ private int mouseDown;
+ private int currWid;
+
+ // Layout constants for the scrollbar.
+ private static final int SCROLLBAR_TRACK_PADDING = 8; // dp
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState)
+ {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.main);
+ View hand = findViewById(R.id.hand);
+ hand.setTag(0);
+ hand.setOnClickListener(this);
+ Configuration conf = getResources().getConfiguration();
+ notifyOrientation(conf);
+ SurfaceView sv = findViewById(R.id.surf);
+ sv.setTag(0);
+ sv.setTag(R.id.scroll, new ScrollHolder());
+ SurfaceView tv = findViewById(R.id.surf2);
+ tv.setTag(1);
+ tv.setTag(R.id.scroll, new ScrollHolder());
+ svs = new SurfaceView[]{
+ sv, tv
+ };
+
+ // Build the foreground as a LayerDrawable containing the vertical
+ // scrollbar thumb pinned to the right edge (gravity=right). The C++
+ // side updates the thumb's top/bottom insets via JNI to reflect the
+ // current scroll position and length.
+ float density = getResources().getDisplayMetrics().density;
+ int sbWidth = (int)(SCROLLBAR_TRACK_PADDING * density);
+ mScrollbarWidth = sbWidth;
+ Shape rect = new RectShape();
+ ShapeDrawable thumb = new ShapeDrawable(rect);
+ thumb.getPaint().setColor(0x80ffffff);
+ thumb.setAlpha(0);
+ sv.setForeground(thumb);
+ thumb = new ShapeDrawable(rect);
+ thumb.getPaint().setColor(0x80ffffff);
+ thumb.setAlpha(0);
+ tv.setForeground(thumb);
+
+ sv.getHolder().addCallback(this);
+ tv.getHolder().addCallback(this);
+ sv.setOnGenericMotionListener(this);
+ tv.setOnGenericMotionListener(this);
+ sv.setOnTouchListener(this);
+ tv.setOnTouchListener(this);
+ registerForContextMenu(sv);
+ inited = 2;
+ }
+
+ public final void sendDelayed(final long timer, long timeout) {
+ if (timeout < 0) {
+ svs[0].postOnAnimation(new Runnable(){
+ public void run() {
+ MainHandler.nativeRun(timer);
+ }
+ });
+ } else {
+ Message msg = Message.obtain(hand, MainHandler.HD_NRUN, timer);
+ hand.sendMessageDelayed(msg, timeout);
+ }
+ }
+
+ @Override
+ public native boolean onCreateOptionsMenu(Menu menu);
+
+ private void notifyOrientation(Configuration conf) {
+ View dv = getWindow().getDecorView();
+ boolean isVert = conf.orientation == Configuration.ORIENTATION_PORTRAIT;
+ dv.setSystemUiVisibility(
+ isVert ? 0 :
+ (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN)
+ );
+ LinearLayout v = findViewById(R.id.drawer);
+ View hand = findViewById(R.id.hand);
+ RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams)v.getLayoutParams();
+ ViewGroup.LayoutParams handParams = hand.getLayoutParams();
+ DisplayMetrics dm = getResources().getDisplayMetrics();
+ int dp8 = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 8, dm);
+ int opening = 1-(Integer)hand.getTag();
+ if (isVert) {
+ params.width = params.MATCH_PARENT;
+ params.height = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 320, dm);
+ params.removeRule(RelativeLayout.ALIGN_PARENT_END);
+ params.addRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ params.setMarginEnd(0);
+ handParams.height = dp8;
+ handParams.width = params.MATCH_PARENT;
+ params.bottomMargin = opening*(dp8 - params.height);
+ v.setOrientation(LinearLayout.VERTICAL);
+ } else {
+ params.width = (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 400, dm);
+ params.height = params.MATCH_PARENT;
+ params.removeRule(RelativeLayout.ALIGN_PARENT_BOTTOM);
+ params.addRule(RelativeLayout.ALIGN_PARENT_END);
+ handParams.height = params.MATCH_PARENT;
+ handParams.width = dp8;
+ params.setMarginEnd(opening*(dp8 - params.width));
+ params.bottomMargin = 0;
+ v.setOrientation(LinearLayout.HORIZONTAL);
+ }
+ }
+
+ @Override
+ public void onConfigurationChanged(Configuration newConfig)
+ {
+ super.onConfigurationChanged(newConfig);
+ notifyOrientation(newConfig);
+ }
+
+ @Override
+ public void surfaceChanged(SurfaceHolder p1, int format, int width, int height) {
+ int i = p1==svs[0].getHolder() ? 0:1;
+ nativeOnWindowChanged(i);
+ }
+
+ @Override
+ public void surfaceCreated(SurfaceHolder p1) {
+ if (inited >= 0)
+ inited--;
+ if (inited == 0) {
+ String[] lcs;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ LocaleList lclst = getResources().getConfiguration().getLocales();
+ final int siz = lclst.size();
+ lcs = new String[siz];
+ for (int i=0; i= 0 && currY >= 0)
+ v.showContextMenu(currX, currY);
+ else
+ v.showContextMenu();
+ }
+
+ @Override
+ public void onCreateContextMenu(ContextMenu menu, View v, ContextMenu.ContextMenuInfo menuInfo) {
+ nativeOnCreateContextMenu(menu, pmenu);
+ }
+
+ @Override
+ public void onContextMenuClosed(Menu menu)
+ {
+ nativeOnContextMenuClosed(pmenu);
+ pmenu = 0;
+ }
+
+ @Override
+ public boolean onContextItemSelected(MenuItem item) {
+ return onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onDestroy() {
+ nativeClear();
+ super.onDestroy();
+ }
+
+ public void showEditor(int wid, float x, float y, float fontHeight, float minWidth, boolean isMono, String text) {
+ EditText ed = new EditText(this);
+ if (isMono) ed.setTypeface(Typeface.MONOSPACE);
+ ed.setImeOptions(EditorInfo.IME_ACTION_DONE);
+ ed.setText(text);
+ ed.setOnEditorActionListener(this);
+ ed.setTextColor(0xff000000);
+ ed.setFocusable(true);
+ ed.setTag(wid);
+ PopupWindow pw = new PopupWindow(
+ ed,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ true);
+ this.pw = pw;
+ pw.setBackgroundDrawable(new ColorDrawable(0xffffffff));
+ pw.setAnimationStyle(0);
+ pw.setOutsideTouchable(false);
+ pw.setTouchable(true);
+ pw.setTouchModal(false);
+ pw.setInputMethodMode(PopupWindow.INPUT_METHOD_NEEDED);
+ pw.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+ pw.setOnDismissListener(this);
+ float density = getDensity();
+ int[] loc = new int[2];
+ svs[wid].getLocationInWindow(loc);
+ loc[0] += (int)(x * density)-ed.getPaddingLeft();
+ loc[1] += (int)(y * density)-ed.getPaddingTop();
+ pw.showAtLocation(getWindow().getDecorView(), Gravity.TOP|Gravity.LEFT, loc[0], loc[1]);
+ ed.requestFocus();
+ }
+
+ // ---- Scrollbar geometry (called from C++ via JNI) ----
+
+ // Stores the scrollbar configuration and redraws the thumb.
+ // All geometry arguments are in normalized scrollbar units.
+ protected void setScrollbar(int wid, float min, float max, float page, float pos, boolean visible) {
+ SurfaceView v = svs[wid];
+ ScrollHolder holder = (ScrollHolder)v.getTag(R.id.scroll);
+ holder.min = min;
+ holder.max = max;
+ holder.page = page;
+ holder.pos = pos;
+ holder.visible = visible;
+ Drawable scrollbar = v.getForeground();
+ if (scrollbar == null) return;
+ int height = v.getHeight();
+ if (!visible || height <= 0 ||
+ max <= min) {
+ scrollbar.setAlpha(0);
+ return;
+ }
+
+ double range = Math.max(1e-6, max - min);
+ // Thumb length is proportional to the page size within the range.
+ int thumbH = (int)(page / (max - min) * height);
+
+ double ratio = (pos - min) / range;
+ if (ratio < 0) ratio = 0;
+ if (ratio > 1) ratio = 1;
+ int top = (int)(ratio * height);
+ int bottom = top + thumbH;
+ if (bottom > height) bottom = height;
+
+ // Pin the thumb to the right edge; its width is controlled by the
+ // right inset relative to the right gravity of the layer.
+ int width = v.getWidth();
+ scrollbar.setBounds(width - mScrollbarWidth, top, width, bottom);
+ scrollbar.setAlpha(0x80);
+ }
+
+ // Callback from C++ that the scroll position was adjusted by the user.
+ private native void nativeOnScrollbarAdjusted(int wid, double pos);
+
+ @Override
+ public void onDismiss() {
+ PopupWindow pw = this.pw;
+ if (pw != null) {
+ EditText ed = (EditText)pw.getContentView();
+ nativeOnEditorDone((Integer)ed.getTag(), ed.getText().toString());
+ }
+ }
+
+ public void hideEditor() {
+ PopupWindow pw = this.pw;
+ this.pw = null;
+ if (pw != null) {
+ pw.dismiss();
+ }
+ }
+
+ public boolean isEditorVisible() {
+ return pw != null && pw.isShowing();
+ }
+
+ boolean showing = false;
+ int ret = BTN_CANCEL;
+
+ public int showMessageDialog(String title, String msg, int btn) {
+ if (showing) {
+ return BTN_CANCEL;
+ }
+ showing = true;
+ AlertDialog.Builder bd = new AlertDialog.Builder(this);
+ bd.setTitle(title);
+ bd.setMessage(msg);
+ bd.setOnCancelListener(this);
+ bd.setOnDismissListener(this);
+ if ((btn&(BTN_OK|BTN_YES)) != 0) {
+ bd.setPositiveButton((btn&BTN_YES)!=0?R.string.yes:android.R.string.ok, this);
+ }
+ if ((btn&(BTN_NO|BTN_CANCEL)) != 0) {
+ bd.setNegativeButton((btn&BTN_NO)!=0?R.string.no:android.R.string.cancel, this);
+ }
+ if (btn == (BTN_CANCEL|BTN_YES|BTN_NO)) {
+ bd.setNeutralButton(android.R.string.cancel, this);
+ }
+ ret = btn;
+ bd.create().show();
+ try {Looper.loop();} catch (RuntimeException re) {}
+ showing = false;
+ return ret;
+ }
+
+ @Override
+ public void onClick(DialogInterface p1, int p2) {
+ int dret;
+ switch (p2) {
+ case DialogInterface.BUTTON_POSITIVE:
+ dret = (ret&BTN_OK)==0?BTN_YES:BTN_OK;
+ break;
+ case DialogInterface.BUTTON_NEGATIVE:
+ dret = (ret&BTN_NO)==0?BTN_CANCEL:BTN_NO;
+ break;
+ case DialogInterface.BUTTON_NEUTRAL:
+ default:
+ dret = BTN_CANCEL;
+ break;
+ }
+ ret = dret;
+ }
+
+ @Override
+ public void onCancel(DialogInterface p1) {
+ ret = BTN_CANCEL;
+ }
+
+ @Override
+ public void onDismiss(DialogInterface p1) {
+ hand.obtainMessage(MainHandler.HD_RET).sendToTarget();
+ }
+
+ private String fname;
+ public String showFileDialog(boolean isSave, String title, String[] exts) {
+ if (showing) return null;
+ showing = true;
+ Intent it = new Intent(isSave?Intent.ACTION_CREATE_DOCUMENT:Intent.ACTION_OPEN_DOCUMENT);
+ it.setType("*/*");
+ it.addCategory(Intent.CATEGORY_OPENABLE);
+ if (isSave && title != null) {
+ it.putExtra(Intent.EXTRA_TITLE, title);
+ }
+ startActivityForResult(it, 0);
+ fname = null;
+ try { Looper.loop(); } catch (RuntimeException re) {}
+ showing = false;
+ String fnam = fname;
+ if (fnam == null) return null;
+ // Apply filters
+ String name = fileName(fnam);
+ int pt = name.lastIndexOf(".");
+ int l = exts == null ? 0 : exts.length;
+ if (pt >= 0 && l > 0) {
+ String ext = name.substring(pt+1, name.length());
+ int i=0;
+ for (; i= 0 && y <= height)) {
+ mScrollDragging = true;
+ nativeOnScrollbarAdjusted(wid, (y - thumbH * 0.5)*range/height);
+ }
+ } else if (act == MotionEvent.ACTION_MOVE && mScrollDragging) {
+ nativeOnScrollbarAdjusted(wid, (y - thumbH * 0.5)*range/height);
+ } else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) {
+ mScrollDragging = false;
+ }
+ }
+
+ private boolean xInScrollbar(MotionEvent ev, int sbLeft, int width) {
+ return ev.getX() >= sbLeft && ev.getX() <= width;
+ }
+
+ @Override
+ public boolean onKeyDown(int keyCode, KeyEvent ev) {
+ if (!super.onKeyDown(keyCode, ev))
+ svs[0].postOnAnimation(new Key(0, ev));
+ return true;
+ }
+
+ @Override
+ public boolean onKeyUp(int keyCode, KeyEvent ev) {
+ if (!super.onKeyUp(keyCode, ev))
+ svs[0].postOnAnimation(new Key(0, ev));
+ return true;
+ }
+
+ public Surface onWinAdded(int wid) {
+ return svs[wid].getHolder().getSurface();
+ }
+
+ public void setWinTitle(String title) {
+ getActionBar().setTitle(title);
+ }
+
+ @Override
+ public boolean onEditorAction(TextView p1, int p2, KeyEvent p3) {
+ nativeOnEditorDone((Integer)p1.getTag(), p1.getText().toString());
+ return true;
+ }
+
+ private void toast(String tst) {
+ Toast.makeText(this, tst, 0).show();
+ }
+
+ public static String[] getSysFonts() {
+ Set fonts = SystemFonts.getAvailableFonts();
+ String[] arr = new String[fonts.size()];
+ int i=0;
+ for (Font f:fonts) {
+ arr[i++] = f.getFile().getPath();
+ }
+ return arr;
+ }
+
+ @Override
+ public void onClick(View v) {
+ int opening = (Integer)v.getTag();
+ LinearLayout lr = findViewById(R.id.drawer);
+ ViewGroup.LayoutParams handParams = v.getLayoutParams();
+ ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams)lr.getLayoutParams();
+ DisplayMetrics dm = getResources().getDisplayMetrics();
+ if (lr.getOrientation() == LinearLayout.VERTICAL) {
+ params.bottomMargin = opening*(handParams.height - (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 320, dm));
+ } else {
+ params.setMarginEnd(opening*(handParams.width - (int)TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 400, dm)));
+ }
+ lr.requestLayout();
+ v.setTag(1-opening);
+ }
+
+ private native void nativeInit(AssetManager amgr, String[] strs);
+ private native void nativeClear();
+ private native void nativeSetSurface(int wid, Surface suf);
+ protected static native boolean nativeOnMotionEvent(int wid, int action, float x, float y, double dist, int button, int metastate);
+ protected static native boolean nativeOnKeyEvent(int wid, int keystate, int keyCode, int metastate);
+ private static native void nativeOnWindowChanged(int winId);
+ private static native void nativeOnEditorDone(int wid, String text);
+ private static native void nativeOnCreateContextMenu(Menu menu, long pemnu);
+ private static native void nativeOnContextMenuClosed(long pmenu);
+
+ static class Motion implements Runnable {
+ int id, act, button, metastate;
+ float x, y;
+ double dist;
+ public Motion(int wid, int ac, float x, float y, double dis, int btn, int mt) {
+ id = wid;
+ act = ac;
+ this.x = x;
+ this.y = y;
+ dist = dis;
+ button = btn;
+ metastate = mt;
+ }
+ public void run() {
+ nativeOnMotionEvent(id, act, x, y, dist, button, metastate);
+ }
+ }
+
+ static class Key implements Runnable {
+ int id, act, keyenc, meta;
+ public Key(int wid, KeyEvent ke) {
+ id = wid;
+ act = ke.getAction();
+ int kcode = ke.getKeyCode();
+ if (kcode >= KeyEvent.KEYCODE_F1 && kcode <= KeyEvent.KEYCODE_F12)
+ keyenc = -kcode;
+ else
+ keyenc = ke.getUnicodeChar(0);
+ meta = ke.getMetaState();
+ }
+ public void run() {
+ nativeOnKeyEvent(id, act, keyenc, meta);
+ }
+ }
+
+ static class ScrollHolder {
+ float min, max, page, pos;
+ boolean visible;
+ }
+}
diff --git a/src/platform/platform.cpp b/src/platform/platform.cpp
index a57ff5ec3..dad98bf63 100644
--- a/src/platform/platform.cpp
+++ b/src/platform/platform.cpp
@@ -15,12 +15,14 @@
#include