From daa409a166d1d6826ac17cd00048536814244864 Mon Sep 17 00:00:00 2001 From: RainbowC0 <15760207563@139.com> Date: Sat, 29 Aug 2026 22:12:09 +0800 Subject: [PATCH 1/5] Add Android front end --- CMakeLists.txt | 79 +- README.md | 62 + res/AndroidManifest.xml | 30 + res/CMakeLists.txt | 50 +- res/android/drawable-hdpi/ic_launcher.png | Bin 0 -> 734 bytes res/android/drawable-mdpi/ic_launcher.png | Bin 0 -> 352 bytes res/android/drawable-xhdpi/ic_launcher.png | Bin 0 -> 586 bytes res/android/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 931 bytes res/android/values/string.xml | 4 + src/CMakeLists.txt | 78 +- src/export.cpp | 8 + src/mouse.cpp | 12 +- src/platform/guidroid.cpp | 1327 +++++++++++++++++ .../java/com/solvespace/MainHandler.java | 17 + .../com/solvespace/SolveSpaceActivity.java | 600 ++++++++ src/platform/platform.cpp | 119 +- src/platform/platform.h | 21 + src/platform/platformbase.cpp | 7 +- src/render/gl3shader.cpp | 4 +- src/render/gl3shader.h | 2 +- src/resource.cpp | 22 +- src/resource.h | 2 + src/slvs/CMakeLists.txt | 11 +- src/solvespace.cpp | 2 +- src/toolbar.cpp | 27 +- src/ui.h | 2 + src/util.h | 9 +- 27 files changed, 2427 insertions(+), 68 deletions(-) create mode 100644 res/AndroidManifest.xml create mode 100644 res/android/drawable-hdpi/ic_launcher.png create mode 100644 res/android/drawable-mdpi/ic_launcher.png create mode 100644 res/android/drawable-xhdpi/ic_launcher.png create mode 100644 res/android/drawable-xxhdpi/ic_launcher.png create mode 100644 res/android/values/string.xml create mode 100644 src/platform/guidroid.cpp create mode 100644 src/platform/java/com/solvespace/MainHandler.java create mode 100644 src/platform/java/com/solvespace/SolveSpaceActivity.java diff --git a/CMakeLists.txt b/CMakeLists.txt index 417111e6c..3c1738893 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,17 @@ 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() +endif() + set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin) set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin) if("${CMAKE_GENERATOR}" STREQUAL "Xcode") @@ -139,6 +150,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 +292,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 +301,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 +316,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 +364,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 +428,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 +558,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..cdbb55b7c 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,68 @@ 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 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 +# Linux +sudo apt install default-jre cmake +# macOS +brew install openjdk@17 cmake +# Windows +winget install --id EclipseAdoptium.Temurin.17.JDK -e -h +winget install --id Kitware.CMake -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_CLI=OFF -DENABLE_TESTS=OFF + -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 eigen +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. -DENABLE_CLI=OFF -DENABLE_TESTS=OFF + -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$PREFIX/bin -DENABLE_OPENMP=ON + -DENABLE_CLI=OFF -DENABLE_TESTS=OFF -DENABLE_LTO=ON -DCMAKE_BUILD_TYPE=Debug +cmake --build build -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..8e98fdf59 --- /dev/null +++ b/res/AndroidManifest.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + \ 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 0000000000000000000000000000000000000000..99847c219814e635e13b619f12a2dcd151dddaf3 GIT binary patch literal 734 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY1|&n@ZgvM!jKx9jP7LeL$-D$|Sc;uILpXq- zh9jkefq|*R)5S5QV$R#!`?G};CD=c_|GX@>?pB(HkikC*u73=g#~S#$uAV%*w0m-W zV{@~`t-uS8U*uoh*zZs{;n4z**@}@Y>d`^w6St{srq z$#q-@Vhqa8on@A&aL6%aW;QZVz)v{qoNs=0^Q<~kjDf#MO!!qXh7;SYO+T*LP_FUQ z@OzT(?wb}Cu8s1`XFu-$9h$Af*!e)OVP?T|uj=KklV_F-*UQanEwPL*Wp%jEl+-Qx zsPf6Gpw^7)dY|JaZ`0Rk*4;W?*?MjsB+EWh`?KhN-^pyey$hNC__CoVjF?@+Pb{L*!n zJVlB3HP;taz1=1&IsNNIn`?{?F4yk$o9C59FAiIsYI}ZmPtfs1uceo=ru@8kuQ^6Z zwd=v+#3fz7KbQDQJ p-%0I{(rPmO*bPE`X1*QT2iEIb=-Hu6r;dHCp&)1;OK3IR?6 zj2zw*^4LRdJED_TFf**$#4|hnmaRC@B-Ikvh?11Vl2ohYqEsNoU}Ruus%v1ZYh(~& wXl!L_U}a*gZD4F=V6Z`6MF>ShZhlH;S|x4`2c71u18QLKboFyt=akR{0Omwt`Tzg` literal 0 HcmV?d00001 diff --git a/res/android/drawable-xhdpi/ic_launcher.png b/res/android/drawable-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..a2545e12d383c1955e5fa0603f1397fec6956120 GIT binary patch literal 586 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGojKx9jP7LeL$-D$|Sc;uILpXq- zh9ji|DEGzF#WAE}&f7bNeun~Nj(_~G?81KAQ~dFh7mQYyU#yK;(f#~_a0Tig{NbLlTRC za)Sip5e@}621BL}L5CiQP(=J*#@XLy`LY>rE~>kC(qw;7?)m&Oy#r^~e0jDyp==YQ zL=#xg#<|vPCM#cC?O_fVb)9&mu+bJ~0v1ED3r(uiN}A)_w()&oTJs0Njz8Zr9+v)3 z-7k|qdFktyGkg1%|4e@K{J58u!(rt%?LEh5az9HoZ-1Dy)I7e;`1#p0p=sCNGQ9ry z|5f@A8D^lH#Q3J?O3sS3nCD(!7JJ?Ib?37=#_u1quUe#G_OG_Ih(F@_>3nZ><|Qo* zNo(h{&GX;X%DrOy?)9G@XKveY=H=6Shm7v!e3{@seO`I>d-roY?d6$+E}e4x@yNDY zE@kiLS9bH&KA!n!X2{YB4y)HJ9RL4r$n5%7^EYzsj(1m86pg_1F_^0+S{|%6HQGy*3Lkt^K@6ThMqJL<|Dr1m{r>mdK II;Vst0KMn!h5!Hn literal 0 HcmV?d00001 diff --git a/res/android/drawable-xxhdpi/ic_launcher.png b/res/android/drawable-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..00ce5024b44b1fcfc2a415f006c481015a0d7480 GIT binary patch literal 931 zcmeAS@N?(olHy`uVBq!ia0vp^6F``Q4M;wBd$a>cF%}28J29*~C-V}>VJUX<4B-HR z8jh3>1_owdPZ!6KiaBrZ?$5g%Akp^mefA};3@KA#Z^1_Cj0qWLR~W<(s9u=3Raxil zkwb@$afve-zM9c^htXqgOUr~``hl#zH4+m94i_$cQu@fGvM$K#%Bor^+fB7e>W0if z>yW^V^ju?ZN&Yh(OBb;>yzXsKP*m{X;1FVJY9Wq!%9a1YE~}`wxwDq%FXnFEd;dka zy-%e_tHJ`qsWxl)&qi%beK*gi+Q#*v02?Qxqm#n~0Ra^j7S7EZICtEzyZ`P@`J()T ze2QCUoqhH^vp+NF1~Wnpl5&cfClA#exbj_E{HtSS!1?Uj<_USbs!JkYrtjQ#X|HH( z+2;kfEY{39{@BDg~ zYQfbZS;y<-ZA$Fdel2&Gb>~RDez$n`G5_G5G5^9|eCxhbQLw(cBffCG-HxK?692b7 zF}QbT`OTbjPu2=uXKXh>P6D6iw5HxwnDg@W>-%N@pL*pnTi8i#J0^L&>D1FC*=m&3 z0#w>t6aBR4?CZ2?w{Q1)i`p9+ugmrgT=+fK&T{>m$_;B~A3Z*?X3e*>Pr3&_cR5Rd zQUWY$kKVhKHEqW6^hEdfdgXfk&Lv&-@2`4&$+u~7de46>C-e4w?bjRG(^yblhnZH9 d5;uq$u)%(gegALf_tVuuTu)a&mvv4FO#l-+aXJ71 literal 0 HcmV?d00001 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..3442a78e4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,6 +22,11 @@ if(USE_QT_GUI) qt_add_resources(RESOURCE_FILES ../res/qt-resources.qrc) endif() +if(ANDROID) + set_property(TARGET dxfrw PROPERTY + POSITION_INDEPENDENT_CODE ON) +endif() + # solvespace dependencies add_library(slvs_deps INTERFACE) target_include_directories(slvs_deps SYSTEM INTERFACE @@ -38,7 +43,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 +111,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 +178,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 +273,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 +420,50 @@ 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}) + else() + set(HOME $ENV{HOME}) + 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 dir /s /b classes\\*.class > 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 ${CMAKE_COMMAND} -E make_directory ${LIBDIR} + COMMAND ${CMAKE_STRIP} -s + -o ${LIBDIR}/libsolvespace.so + bin/libsolvespace.so + COMMAND jar -u -f bin/res.apk classes.dex ${LIBDIR}/libsolvespace.so + COMMAND ${BUILD_TOOL}/apksigner sign --ks ${kspath} --ks-pass pass:android --v2-signing-enabled false --out bin/app.apk bin/res.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/export.cpp b/src/export.cpp index 9b2a2c1af..cb4768297 100644 --- a/src/export.cpp +++ b/src/export.cpp @@ -1018,9 +1018,17 @@ void SolveSpaceUI::ExportMeshAsThreeJsTo(FILE *f, const Platform::Path &filename if(filename.HasExtension("html")) { fprintf(f, htmlbegin, THREE_FN.c_str(), + #ifdef __ANDROID__ + LoadString("threejs/"+THREE_FN).c_str(), + #else LoadStringFromGzip("threejs/" + THREE_FN + ".gz").c_str(), + #endif HAMMER_FN.c_str(), + #ifdef __ANDROID__ + LoadString("threejs/"+HAMMER_FN).c_str(), + #else LoadStringFromGzip("threejs/" + HAMMER_FN + ".gz").c_str(), + #endif CONTROLS_FN.c_str(), LoadString("threejs/" + CONTROLS_FN).c_str()); } diff --git a/src/mouse.cpp b/src/mouse.cpp index 76173bf6a..e6c19c3fe 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. @@ -1367,6 +1368,10 @@ void GraphicsWindow::MouseLeftUp(double mx, double my, bool shiftDown, bool ctrl default: break; // do nothing } + + if (SS.showToolbar) { + ToolbarMouseUp((int)mx, (int)my); + } } void GraphicsWindow::EditConstraint(hConstraint constraint) { @@ -1524,6 +1529,7 @@ void GraphicsWindow::MouseLeave() { if(!context.active) { hover.Clear(); toolbarHovered = Command::NONE; + toolbarPressed = Command::NONE; Invalidate(); } SS.extraLine.draw = false; diff --git a/src/platform/guidroid.cpp b/src/platform/guidroid.cpp new file mode 100644 index 000000000..3d0b9b2fe --- /dev/null +++ b/src/platform/guidroid.cpp @@ -0,0 +1,1327 @@ +//----------------------------------------------------------------------------- +// 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; +// 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 ANativeWindow *g_NativeWindow = nullptr; +static EGLDisplay gDisplay = EGL_NO_DISPLAY; +static EGLSurface gSurface = EGL_NO_SURFACE; + +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; +} + +//----------------------------------------------------------------------------- +// EGL and GL context management +//----------------------------------------------------------------------------- + +class EGLContextManager { +private: + EGLContext m_Context; + static EGLConfig m_Config; + +public: + EGLContextManager() : + m_Context(EGL_NO_CONTEXT) {} + + 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 (!g_NativeWindow) return false; + gSurface = eglGetCurrentSurface(EGL_DRAW); + if (gSurface == EGL_NO_SURFACE) { + gSurface = eglCreateWindowSurface(gDisplay, m_Config, (EGLNativeWindowType)g_NativeWindow, NULL); + dbp("create surface"); + } else dbp("surf %p", gSurface); + + // Make current + if (!eglMakeCurrent(gDisplay, gSurface, gSurface, 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 (gSurface != EGL_NO_SURFACE) { + eglDestroySurface(gDisplay, gSurface); + gSurface = 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; + } + } + + void SwapBuffers() { + if (gDisplay != EGL_NO_DISPLAY && gSurface != EGL_NO_SURFACE) { + eglSwapBuffers(gDisplay, gSurface); + } + } + + bool MakeCurrent() { + return eglMakeCurrent(gDisplay, gSurface, gSurface, m_Context); + } + + bool isCurrent() const { + return eglGetCurrentContext() == 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) { + env->CallObjectMethod(item, g_SetAlphabeticShortcut, alt); + } + 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); + } +}; + +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; +static int g_CurrentWindow = 0; +extern "C" JNIEXPORT void JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId); +class WindowImplAndroid final : public Window { +private: + EGLContextManager m_EGLContext; + bool m_Visible; + bool m_FullScreen; + std::string m_Title; + MenuBarRef m_MenuBar; + double m_ScrollMin = 0.0; + double m_ScrollMax = 1.0; + double m_ScrollPage = 0.0; + double m_ScrollPos = 0.0; + bool m_ScrollVis = false; + +public: + int id; + + WindowImplAndroid(Window::Kind kind) : + m_Visible(false), + m_FullScreen(false) { + // Android typically has only one window (the activity) + JNIEnv *env = GetJNIEnv(); + if (env && g_Activity) { + env->CallVoidMethod(g_Activity, g_OnWinAdded, kind == Kind::TOPLEVEL); + } + m_EGLContext.Initialize(); + } + + ~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; + + jmethodID getDensity = g_GetDensity; + if (getDensity) { + jfloat density = env->CallFloatMethod(g_Activity, getDensity); + return density; + } + return 1.0; + } + + double GetPixelDensity() override { + return GetDevicePixelRatio() * 160; + } + + bool IsVisible() override { + return m_Visible && g_NativeWindow != nullptr; + } + + 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 + JNIEnv *env = GetJNIEnv(); + if (env) + Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(env, env->GetObjectClass(g_Activity), id); + } + + 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; + JNIEnv* env = GetJNIEnv(); + if (env && g_Activity) { + jmethodID setTitleMethod = g_SetTitle; + if (setTitleMethod) { + jstring jTitle = env->NewStringUTF(title.c_str()); + env->CallVoidMethod(g_Activity, setTitleMethod, id, jTitle); + env->DeleteLocalRef(jTitle); + } + } + } + + void SetMenuBar(MenuBarRef newMenuBar) override { + m_MenuBar = newMenuBar; + } + + void GetContentSize(double *width, double *height) override { + float pixelRatio = GetDevicePixelRatio(); + *width = ANativeWindow_getWidth(g_NativeWindow) / pixelRatio; + *height = ANativeWindow_getHeight(g_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, + (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; + if (id == g_CurrentWindow) + UpdateScrollbar(); + } + + void ConfigureScrollbar(double min, double max, double pageSize) override { + m_ScrollMin = min; + m_ScrollMax = max; + m_ScrollPage = pageSize; + if (id == g_CurrentWindow) + UpdateScrollbar(); + } + + double GetScrollbarPosition() override { + return m_ScrollPos; + } + + void SetScrollbarPosition(double pos) override { + m_ScrollPos = pos; + if (id == g_CurrentWindow) + 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, + m_ScrollMin, m_ScrollMax, m_ScrollPage, + m_ScrollPos, m_ScrollVis); + } + + void Invalidate() override { + JNIEnv *env = GetJNIEnv(); + if (env && g_Activity) { + env->CallVoidMethod(g_Activity, g_SendDelayedMethod, this, -1L); + } + } + + __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__ void Render() { + if (m_EGLContext.isCurrent()) { + if (onRender) { + onRender(); + m_EGLContext.SwapBuffers(); + } + } + } + + __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); + window->id = g_NextWindowId; + g_Windows[g_NextWindowId++] = window; + return window; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject surface, jobject jamgr) { + g_Activity = env->NewGlobalRef(thiz); + amgr = AAssetManager_fromJava(env, jamgr); + g_NativeWindow = ANativeWindow_fromSurface(env, surface); + ANativeWindow_acquire(g_NativeWindow); + 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; + g_CurrentWindow = 0; + MenuItemImplAndroid::nextId = 1; + MenuItemImplAndroid::item_map.clear(); + env->DeleteGlobalRef(g_Activity); + g_Activity = nullptr; + if (g_NativeWindow) { + ANativeWindow_release(g_NativeWindow); + g_NativeWindow = nullptr; + } + amgr = nullptr; +} + +void JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId) { + if (winId < g_NextWindowId) { + g_CurrentWindow = winId; + auto win = g_Windows[winId]; + win->MakeCurrent(); + win->Invalidate(); + } +} + +static time_t lastDown = 0; +static double lastDist = 0.; +#define DBL_TIMEOUT 300L + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass jc, jint act, jfloat x, jfloat y, jdouble dist, jint button, jint state) { + if (g_CurrentWindow >= g_NextWindowId) { + return false; + } + auto win = g_Windows[g_CurrentWindow]; + MouseEvent event = {.x = x, .y = y}; + event.shiftDown = state & AMETA_SHIFT_ON; + event.controlDown = state & AMETA_CTRL_ON; + 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; + event.type = tm - lastDown < DBL_TIMEOUT ? MouseEvent::Type::DBL_PRESS : 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; + 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 keyact, jint keycode, jint metastate) { + if (g_CurrentWindow >= g_NextWindowId) { + return false; + } + auto win = g_Windows[g_CurrentWindow]; + 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 (keycode >= AKEYCODE_F1 && keycode <= AKEYCODE_F12) { + event.key = KeyboardEvent::Key::FUNCTION; + event.num = keycode - AKEYCODE_F1 + 1; + } else { + event.key = KeyboardEvent::Key::CHARACTER; + event.num = keycode; + } + 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, jstring str) { + auto win = g_Windows[g_CurrentWindow]; + const char * cstr = env->GetStringUTFChars(str, NULL); + win->onEditingDone(cstr); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeOnScrollbarAdjusted(JNIEnv *env, jobject thiz, jdouble pos) { + if (g_CurrentWindow >= g_NextWindowId) { + return; + } + auto win = g_Windows[g_CurrentWindow]; + auto adjust = win->onScrollbarAdjusted; + if (adjust) { + adjust(pos); + } +} + +extern "C" JNIEXPORT jboolean JNICALL +Java_com_solvespace_SolveSpaceActivity_onCreateOptionsMenu(JNIEnv *env, jobject thiz, jobject menu) { + int currWin = g_CurrentWindow; + if (currWin >= g_NextWindowId) + return false; + env->CallVoidMethod(menu, g_Clear); + g_Windows[currWin]->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){ + item->onTrigger(); + 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", + "(FFFFZLjava/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", + "(ILjava/lang/String;)V"); + g_OnWinAdded = env->GetMethodID(activityClass, "onWinAdded", "(Z)V"); + g_GetPreferences = env->GetMethodID(activityClass, "getPreferences", "(I)Landroid/content/SharedPreferences;"); + g_GetDensity = env->GetMethodID(activityClass, "getDensity", "()F"); + g_SetScrollbar = env->GetMethodID(activityClass, "setScrollbar", "(DDDDZ)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;"); + + 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"); + g_SetAlphabeticShortcut = env->GetMethodID(itemClz, "setAlphabeticShortcut", "(C)Landroid/view/MenuItem;"); + 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 { + void RunAfter(unsigned int milliseconds) override { + JNIEnv *env = GetJNIEnv(); + if (!(env && g_Activity)) { + return; + } + env->CallVoidMethod(g_Activity, g_SendDelayedMethod, this, milliseconds); + } +}; + +TimerRef CreateTimer() { + return std::make_shared(); +} + +extern "C" JNIEXPORT void JNICALL +Java_com_solvespace_MainHandler_nativeRun(JNIEnv* env, jclass clazz, void *timer, jboolean frame) { + if (frame) { + ((WindowImplAndroid*)timer)->Render(); + return; + } + auto func = ((Timer*)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..9ea61a474 --- /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, false); + else if (msg.what == HD_RET) + throw new RuntimeException(); + } + + public final static native void nativeRun(long obj, boolean frame); +} diff --git a/src/platform/java/com/solvespace/SolveSpaceActivity.java b/src/platform/java/com/solvespace/SolveSpaceActivity.java new file mode 100644 index 000000000..1a84f51fe --- /dev/null +++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java @@ -0,0 +1,600 @@ +package com.solvespace; + +import android.app.*; +import android.os.*; +import android.view.*; +import android.widget.*; +import android.content.res.*; +import android.content.*; +import android.provider.DocumentsContract; +import android.net.*; +import android.opengl.*; +import android.util.*; +import java.io.*; +import java.util.*; +import android.graphics.*; +import android.graphics.drawable.*; +import android.graphics.fonts.*; +import android.text.*; +import android.view.inputmethod.*; +import java.lang.reflect.*; +import android.view.ContextMenu.*; + +public class SolveSpaceActivity extends Activity +implements SurfaceHolder.Callback2, ActionBar.OnNavigationListener, +View.OnTouchListener, View.OnGenericMotionListener, TextView.OnEditorActionListener, +DialogInterface.OnCancelListener, DialogInterface.OnClickListener, +PopupWindow.OnDismissListener, DialogInterface.OnDismissListener +{ + 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 ArrayList wins; + private ArrayAdapter sa; + private SurfaceView sv; + private PopupWindow pw; + private long pmenu; + + // Vertical scrollbar (rendered as the SurfaceView foreground). + private GradientDrawable mScrollbar; + private int mScrollbarWidth = 6; + private int mViewWidth; + private int mViewHeight; + // Scrollbar geometry in normalized units (matching the C++ side). + private double mScrollMin; + private double mScrollMax; + private double mScrollPage; + private double mScrollPos; + private boolean mScrollVisible; + // True while the user is actively dragging the scrollbar thumb. + private boolean mScrollDragging; + + // Layout constants for the scrollbar. + private static final int SCROLLBAR_TRACK_PADDING = 6; // dp + + @Override + protected void onCreate(Bundle savedInstanceState) + { + Configuration conf = getResources().getConfiguration(); + notifyOrientation(conf); + super.onCreate(savedInstanceState); + SurfaceView sv = new SurfaceView(this); + this.sv = sv; + + // 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; + GradientDrawable thumb = new GradientDrawable(); + thumb.setColor(0x80ffffff); + mScrollbar = thumb; + sv.setForeground(thumb); + + setContentView(sv, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + )); + wins = new ArrayList<>(); + sa = new ArrayAdapter(this, android.R.layout.simple_spinner_item, wins); + sa.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); + ActionBar act = getActionBar(); + act.setDisplayShowTitleEnabled(false); + act.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); + act.setListNavigationCallbacks(sa, this); + sv.getHolder().addCallback(this); + sv.setOnGenericMotionListener(this); + sv.setOnTouchListener(this); + registerForContextMenu(sv); + } + + public final void sendDelayed(final long timer, long timeout) { + if (timeout < 0) { + Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback(){ + public void doFrame(long p) { + MainHandler.nativeRun(timer, true); + } + }); + } 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 v = getWindow().getDecorView(); + v.setSystemUiVisibility( + conf.orientation == Configuration.ORIENTATION_LANDSCAPE ? + (View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | View.SYSTEM_UI_FLAG_FULLSCREEN) + : 0 + ); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) + { + super.onConfigurationChanged(newConfig); + notifyOrientation(newConfig); + } + + @Override + public void surfaceChanged(SurfaceHolder p1, int p2, int p3, int p4) { + mViewWidth = p3; + mViewHeight = p4; + nativeOnWindowChanged(getActionBar().getSelectedNavigationIndex()); + } + + @Override + public void surfaceCreated(SurfaceHolder p1) { + nativeInit(p1.getSurface(), getAssets()); + } + + @Override + public void surfaceDestroyed(SurfaceHolder p1) { + } + + @Override + public void surfaceRedrawNeeded(SurfaceHolder p1) { + } + + public void popup(long pmenu) { + this.pmenu = pmenu; + sv.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(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); + 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 xi = (int)(x * density)-ed.getPaddingLeft(); + int yi = (int)(y * density)+getActionBar().getHeight(); + pw.showAtLocation(getWindow().getDecorView(), Gravity.TOP|Gravity.LEFT, xi, yi); + 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. + private void setScrollbar(double min, double max, double page, double pos, boolean visible) { + mScrollMin = min; + mScrollMax = max; + mScrollPage = page; + mScrollPos = pos; + mScrollVisible = visible; + layoutScrollbar(); + } + + // Updates the LayerDrawable item (thumb) geometry: its horizontal width + // (via the right inset, keeping it pinned to the right gravity), and its + // vertical start/length via top/bottom insets. Hidden when not visible. + private void layoutScrollbar() { + if (mScrollbar == null) return; + if (!mScrollVisible || mViewHeight <= 0 || + mScrollMax <= mScrollMin) { + mScrollbar.setAlpha(0); + return; + } + mScrollbar.setAlpha(0x80); + + int height = mViewHeight; + + double range = Math.max(1e-6, mScrollMax - mScrollMin); + // Thumb length is proportional to the page size within the range. + int thumbH = (int)(mScrollPage / (mScrollMax - mScrollMin) * height); + + double ratio = (mScrollPos - mScrollMin) / 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. + mScrollbar.setBounds(mViewWidth-mScrollbarWidth, top, mViewWidth, bottom); + mScrollbar.invalidateSelf(); + } + + // Called from input handling to feed back the adjusted scroll position. + // pos is in normalized scrollbar units (matching the C++ SCROLLBAR_UNIT). + private void setScrollbarPos(double pos) { + mScrollPos = pos; + // Notify the native side so it can react to the scroll adjustment. + nativeOnScrollbarAdjusted(pos); + } + + // Callback from C++ that the scroll position was adjusted by the user. + private native void nativeOnScrollbarAdjusted(double pos); + + @Override + public void onDismiss() { + PopupWindow pw = this.pw; + if (pw != null) { + nativeOnEditorDone(((EditText)pw.getContentView()).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 + int pt = fnam.lastIndexOf("."); + int l = exts == null ? 0 : exts.length; + if (pt >= 0 && l > 0) { + String ext = fnam.substring(pt+1, fnam.length()); + int i=0; + for (; i= 0 && y <= height)) { + mScrollDragging = true; + setScrollbarPos((y - thumbH * 0.5)*range/height); + } + } else if (act == MotionEvent.ACTION_MOVE && mScrollDragging) { + setScrollbarPos((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) { + return ev.getX() >= sbLeft && ev.getX() <= mViewWidth; + } + + @Override + public boolean onKeyDown(int keyCode, KeyEvent ev) { + if (nativeOnKeyEvent(ev.getAction(), keyCode, ev.getMetaState())) { + return true; + } + return super.onKeyDown(keyCode, ev); + } + + @Override + public boolean onKeyUp(int keyCode, KeyEvent ev) { + if (nativeOnKeyEvent(ev.getAction(), keyCode, ev.getMetaState())) { + return true; + } + return super.onKeyUp(keyCode, ev); + } + + public void onWinAdded(boolean isTop) { + int count = sa.getCount(); + sa.add("#"+count); + sa.notifyDataSetChanged(); + if (isTop) + getActionBar().setSelectedNavigationItem(count); + } + + public void setWinTitle(int winId, String title) { + wins.set(winId, title); + sa.notifyDataSetChanged(); + } + + @Override + public boolean onNavigationItemSelected(int p1, long p2) { + nativeOnWindowChanged(p1); + invalidateOptionsMenu(); + return true; + } + + @Override + public boolean onEditorAction(TextView p1, int p2, KeyEvent p3) { + nativeOnEditorDone(p1.getText().toString()); + return true; + } + + public 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; + } + + private native void nativeInit(Surface suf, AssetManager amgr); + private native void nativeClear(); + protected static native boolean nativeOnMotionEvent(int action, float x, float y, double dist, int button, int metastate); + private static native boolean nativeOnKeyEvent(int keystate, int keyCode, int metastate); + private static native void nativeOnWindowChanged(int winId); + private static native void nativeOnEditorDone(String text); + private static native void nativeOnCreateContextMenu(Menu menu, long pemnu); + private static native void nativeOnContextMenuClosed(long pmenu); +} diff --git a/src/platform/platform.cpp b/src/platform/platform.cpp index a57ff5ec3..87016e14a 100644 --- a/src/platform/platform.cpp +++ b/src/platform/platform.cpp @@ -15,12 +15,14 @@ #include #include - #if defined(WIN32) // Conversely, include Microsoft headers after solvespace.h to avoid clashes. # include # include #else +#ifdef __ANDROID__ +# include +#endif # include # include #endif @@ -154,9 +156,24 @@ Path Path::CurrentDirectory() { std::string Path::FileName() const { std::string fileName = raw; +#ifdef __ANDROID__ + std::string SEPARATOR; + if (fileName.size() > 8 && !fileName.compare(0, 8, "content:")) { + SEPARATOR = "%2F"; + } else { + SEPARATOR = "/"; + } + const size_t siz = SEPARATOR.size(); +#else + constexpr size_t siz = 1; +#endif size_t slash = fileName.rfind(SEPARATOR); - if(slash != std::string::npos) { - fileName = fileName.substr(slash + 1); + if(slash != std::string::npos +#ifdef __ANDROID__ + ||(siz==3 && (slash=fileName.rfind("%3A")) != std::string::npos) +#endif + ) { + fileName = fileName.substr(slash + siz); } return fileName; } @@ -186,6 +203,21 @@ bool Path::HasExtension(std::string theirExt) const { } Path Path::WithExtension(std::string ext) const { +#ifdef __ANDROID__ + // A Storage Access Framework content URI is a document reference, not a + // filesystem path; rewriting it would break the URI. Companion files + // (e.g. autosave "*.slvs~") cannot live next to the document, so map them + // into the app's private storage, keyed by the URI. + if(raw.compare(0, 8, "content:") == 0) { + std::string dir = AndroidInternalStoragePath(); + if(dir.empty()) { + // No Java context available; leave the path untouched rather + // than fabricating a broken document URI. + return *this; + } + return From(dir + "/" + AndroidContentKey(raw.c_str()) + "." + ext); + } +#endif Path withExt = *this; size_t dot = withExt.raw.rfind('.'); if(dot != std::string::npos) { @@ -212,6 +244,14 @@ static void FindPrefix(const std::string &raw, size_t *pos) { *pos = raw.find('\\', slashAt + 1); } } +#elif defined(__ANDROID__) + if(raw.compare(0, 8, "content:") == 0) { + // A Storage Access Framework content URI acts as its own root; there + // are no path components below it that can be expanded/relativized. + *pos = 8; + } else if(!raw.empty() && raw[0] == '/') { + *pos = 1; + } #else if(!raw.empty() && raw[0] == '/') { *pos = 1; @@ -220,9 +260,13 @@ static void FindPrefix(const std::string &raw, size_t *pos) { } bool Path::IsAbsolute() const { +#ifdef __ANDROID__ + return true; +#else size_t pos; FindPrefix(raw, &pos); return pos != std::string::npos; +#endif } // Removes one component from the end of the path. @@ -279,6 +323,13 @@ Path Path::Join(const Path &other) const { // On Windows, additionally prepends the UNC prefix to absolute paths without one. // Returns an empty path if a ".." component would escape from the root. Path Path::Expand(bool fromCurrentDirectory) const { +#ifdef __ANDROID__ + // Storage Access Framework content URIs are opaque to filesystem + // semantics (no "." / "..", no real parent); never mangle them. + if(raw.compare(0, 8, "content:") == 0) { + return *this; + } +#endif Path source; Path expanded; @@ -416,6 +467,31 @@ std::string Path::ToPortable() const { FILE *OpenFile(const Platform::Path &filename, const char *mode) { ssassert(filename.raw.length() == strlen(filename.raw.c_str()), "Unexpected null byte in middle of a path"); +#ifdef __ANDROID__ + if(filename.raw.compare(0, 8, "content:") == 0) { + // Storage Access Framework URIs cannot be opened with fopen(); open + // them through the Java activity's ContentResolver and wrap the + // returned descriptor in a FILE*. The Java side detaches the fd, so + // ownership transfers to us and fclose() will release it. + std::string jmode; + if(strcmp(mode, "rb") == 0) jmode = "r"; + else if(strcmp(mode, "wb") == 0) jmode = "w"; + else if(strcmp(mode, "ab") == 0) jmode = "wa"; + else if(strcmp(mode, "r+b") == 0 || strcmp(mode, "rb+") == 0) jmode = "rw"; + else if(strcmp(mode, "w+b") == 0 || strcmp(mode, "wb+") == 0) jmode = "rwt"; + else if(strcmp(mode, "a+b") == 0 || strcmp(mode, "ab+") == 0) jmode = "rwa"; + else jmode = mode; + + int fd = AndroidOpenContentFile(filename.raw.c_str(), jmode.c_str()); + if(fd < 0) return NULL; + FILE *f = fdopen(fd, mode); + if(f == NULL) { + close(fd); + return NULL; + } + return f; + } +#endif #if defined(WIN32) return _wfopen(Widen(filename.Expand(/*fromCurrentDirectory=*/true).raw).c_str(), Widen(mode).c_str()); #else @@ -433,6 +509,14 @@ bool FileExists(const Platform::Path &filename) { void RemoveFile(const Platform::Path &filename) { ssassert(filename.raw.length() == strlen(filename.raw.c_str()), "Unexpected null byte in middle of a path"); +#ifdef __ANDROID__ + if(filename.raw.compare(0, 8, "content:") == 0) { + // Storage Access Framework URIs must be deleted through the + // Java activity (DocumentsContract), not unlink(). + AndroidDeleteContentFile(filename.raw.c_str()); + return; + } +#endif #if defined(WIN32) _wremove(Widen(filename.Expand().raw).c_str()); #else @@ -536,7 +620,7 @@ static Platform::Path ResourcePath(const std::string &name) { return Path::From("res/" + name); } -#elif !defined(WIN32) +#elif !defined(WIN32) && !defined(__ANDROID__) # if defined(__linux__) static const char *selfSymlink = "/proc/self/exe"; @@ -594,13 +678,36 @@ static Platform::Path ResourcePath(const std::string &name) { #endif #if !defined(WIN32) - +#ifdef __ANDROID__ +AAssetManager * amgr; +static bool ReadAsset(const char *name, std::string *data) { + AAsset * asset = AAssetManager_open(amgr, name, AASSET_MODE_UNKNOWN); + if (!asset) { + return false; + } + off_t size = AAsset_getLength(asset); + data->resize(size); + char *buf = (char*)data->data(); + if (!AAsset_read(asset, buf, size)) { + AAsset_close(asset); + return false; + } + AAsset_close(asset); + return true; +} +#endif const void *LoadResource(const std::string &name, size_t *size) { static std::map cache; auto it = cache.find(name); if(it == cache.end()) { - ssassert(ReadFile(ResourcePath(name), &cache[name]), "Cannot read resource"); + ssassert( + #ifdef __ANDROID__ + ReadAsset(name.c_str(), &cache[name]), + #else + ReadFile(ResourcePath(name), &cache[name]), + #endif + ("Cannot read resource "+name).c_str()); it = cache.find(name); } diff --git a/src/platform/platform.h b/src/platform/platform.h index 771a4cf0b..ecedd5ebc 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -6,6 +6,10 @@ #ifndef SOLVESPACE_PLATFORM_H #define SOLVESPACE_PLATFORM_H +#ifdef __ANDROID__ +#include +#include +#endif #include #include @@ -81,6 +85,23 @@ std::vector InitCli(int argc, char **argv); void *AllocTemporary(size_t size); void FreeAllTemporary(); +// Android Env +#ifdef __ANDROID__ +extern AAssetManager *amgr; +extern JNIEnv* GetJNIEnv(); +// Open a Storage Access Framework content URI through the Java activity's +// ContentResolver. Returns an owned file descriptor (>= 0) on success, or -1 +// on failure. The descriptor must be closed by the caller (e.g. via fclose). +int AndroidOpenContentFile(const char *uri, const char *mode); +// Delete a Storage Access Framework content URI through the Java activity. +void AndroidDeleteContentFile(const char *uri); +// Absolute path of the app's private files directory (getFilesDir()). +std::string AndroidInternalStoragePath(); +// Maps a content URI to a filesystem-safe name (used as a key for +// autosave/backup companions that must live in private storage). +std::string AndroidContentKey(const char *uri); +#endif + } // namespace Platform } // namespace SolveSpace diff --git a/src/platform/platformbase.cpp b/src/platform/platformbase.cpp index 6e3bb8aa0..4c5268fe9 100644 --- a/src/platform/platformbase.cpp +++ b/src/platform/platformbase.cpp @@ -51,11 +51,16 @@ void DebugPrint(const char *fmt, ...) #endif +//----------------------------------------------------------------------------- +// Debug output, on Android +//----------------------------------------------------------------------------- +#if defined(__ANDROID__) + //----------------------------------------------------------------------------- // Debug output, on *nix. //----------------------------------------------------------------------------- -#if !defined(WIN32) +#elif !defined(WIN32) void DebugPrint(const char *fmt, ...) { va_list va; diff --git a/src/render/gl3shader.cpp b/src/render/gl3shader.cpp index 7c4552e61..ebf913cc0 100644 --- a/src/render/gl3shader.cpp +++ b/src/render/gl3shader.cpp @@ -113,7 +113,7 @@ precision highp float; if(infoLen > 1) { std::string infoStr(infoLen, '\0'); glGetShaderInfoLog(shader, infoLen, NULL, &infoStr[0]); - dbp(infoStr.c_str()); + dbp("%s", infoStr.c_str()); } GLint compiled = 0; @@ -150,7 +150,7 @@ void Shader::Init(const std::string &vertexRes, const std::string &fragmentRes, if(infoLen > 1) { std::string infoStr(infoLen, '\0'); glGetProgramInfoLog(program, infoLen, NULL, &infoStr[0]); - dbp(infoStr.c_str()); + dbp("%s", infoStr.c_str()); } GLint linked = 0; diff --git a/src/render/gl3shader.h b/src/render/gl3shader.h index 19f73128b..47797d0d2 100644 --- a/src/render/gl3shader.h +++ b/src/render/gl3shader.h @@ -6,7 +6,7 @@ #ifndef SOLVESPACE_GL3SHADER_H #define SOLVESPACE_GL3SHADER_H -#if defined(WIN32) || defined(__EMSCRIPTEN__) +#if defined(WIN32) || defined(__EMSCRIPTEN__) || defined(__ANDROID__) # define GL_APICALL /*static linkage*/ # define GL_GLEXT_PROTOTYPES # include diff --git a/src/resource.cpp b/src/resource.cpp index 8921d7abe..b92d2e543 100644 --- a/src/resource.cpp +++ b/src/resource.cpp @@ -3,7 +3,9 @@ // // Copyright 2016 whitequark //----------------------------------------------------------------------------- +#ifndef __ANDROID__ #include +#endif #include #include #include "solvespace.h" @@ -27,7 +29,7 @@ std::string LoadString(const std::string &name) { return result; } - +#ifndef __ANDROID__ std::string LoadStringFromGzip(const std::string &name) { size_t deflatedSize; const void *data = Platform::LoadResource(name, &deflatedSize); @@ -59,7 +61,7 @@ std::string LoadStringFromGzip(const std::string &name) { return result; } - +#endif std::shared_ptr LoadPng(const std::string &name) { size_t size; const void *data = Platform::LoadResource(name, &size); @@ -669,7 +671,13 @@ size_t BitmapFont::GetWidth(const std::string &str) { } BitmapFont BitmapFont::Create() { - BitmapFont Font = BitmapFont::From(LoadStringFromGzip("fonts/unifont.hex.gz")); + BitmapFont Font = BitmapFont::From( + #ifdef __ANDROID__ + LoadString("fonts/unifont.hex") + #else + LoadStringFromGzip("fonts/unifont.hex.gz") + #endif + ); // Unifont doesn't have a glyph for U+0020. Font.AddGlyph(0x0020, Pixmap::Create(Pixmap::Format::RGB, 8, 16)); Font.AddGlyph(0xE000, LoadPng("fonts/private/0-check-false.png")); @@ -886,7 +894,13 @@ const VectorFont::Glyph &VectorFont::GetGlyph(char32_t codepoint) { VectorFont *VectorFont::Builtin() { static VectorFont Font; if(Font.IsEmpty()) { - Font = VectorFont::From(LoadStringFromGzip("fonts/unicode.lff.gz")); + Font = VectorFont::From( + #ifdef __ANDROID__ + LoadString("fonts/unicode.lff") + #else + LoadStringFromGzip("fonts/unicode.lff.gz") + #endif + ); } return &Font; } diff --git a/src/resource.h b/src/resource.h index 35ed28907..56b18408b 100644 --- a/src/resource.h +++ b/src/resource.h @@ -26,7 +26,9 @@ namespace Platform { } // namespace Platform std::string LoadString(const std::string &name); +#ifndef __ANDROID__ std::string LoadStringFromGzip(const std::string &name); +#endif std::shared_ptr LoadPng(const std::string &name); class Pixmap { diff --git a/src/slvs/CMakeLists.txt b/src/slvs/CMakeLists.txt index d931f8fa1..438a1bc73 100644 --- a/src/slvs/CMakeLists.txt +++ b/src/slvs/CMakeLists.txt @@ -67,10 +67,16 @@ target_include_directories(slvs PUBLIC ${CMAKE_SOURCE_DIR}/include) set_target_properties(slvs PROPERTIES PUBLIC_HEADER ${CMAKE_SOURCE_DIR}/include/slvs.h - VERSION ${PROJECT_VERSION} - SOVERSION 1 ) +if(ANDROID) + target_link_libraries(slvs PUBLIC ${log-lib}) +else() + set_target_properties(slvs PROPERTIES + VERSION ${PROJECT_VERSION} + SOVERSION 1 + ) + # if(NOT WIN32) install(TARGETS slvs COMPONENT slvs @@ -78,3 +84,4 @@ install(TARGETS slvs ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR} PUBLIC_HEADER DESTINATION ${CMAKE_INSTALL_INCLUDEDIR}) # endif() +endif() \ No newline at end of file diff --git a/src/solvespace.cpp b/src/solvespace.cpp index 6c177c055..f4380aca4 100644 --- a/src/solvespace.cpp +++ b/src/solvespace.cpp @@ -677,7 +677,7 @@ void SolveSpaceUI::UpdateWindowTitles() { if(SS.showFullFilePath) { GW.window->SetTitle(saveFile.raw); } else { - GW.window->SetTitle(saveFile.raw.substr(saveFile.raw.find_last_of("/\\") + 1)); + GW.window->SetTitle(saveFile.FileName()); } } } diff --git a/src/toolbar.cpp b/src/toolbar.cpp index 372b9098c..54c360e4d 100644 --- a/src/toolbar.cpp +++ b/src/toolbar.cpp @@ -131,7 +131,7 @@ bool GraphicsWindow::ToolbarMouseMoved(int x, int y) { window->SetTooltip("", 0, 0, 0, 0); } - return withinToolbar; + return withinToolbar || toolbarPressed != Command::NONE; } bool GraphicsWindow::ToolbarMouseDown(int x, int y) { @@ -141,14 +141,31 @@ bool GraphicsWindow::ToolbarMouseDown(int x, int y) { x += ((int)width/2); y += ((int)height/2); - Command hitCommand; - bool withinToolbar = ToolbarDrawOrHitTest(x, y, NULL, &hitCommand, NULL, NULL); - if(hitCommand != Command::NONE) { - SS.GW.ActivateCommand(hitCommand); + Command commandHit; + bool withinToolbar = ToolbarDrawOrHitTest(x, y, NULL, &commandHit, NULL, NULL); + if (commandHit != toolbarPressed) { + toolbarPressed = commandHit; + } + if (commandHit != toolbarHovered) { + toolbarHovered = commandHit; + Invalidate(); } return withinToolbar; } +bool GraphicsWindow::ToolbarMouseUp(int x, int y) { + double w, h; + window->GetContentSize(&w, &h); + x += ((int)w/2); + y += ((int)h/2); + Command commandHit; + if (ToolbarDrawOrHitTest(x, y, NULL, &commandHit, NULL, NULL) && commandHit == toolbarPressed && commandHit != Command::NONE) { + ActivateCommand(commandHit); + } + toolbarPressed = Command::NONE; + return false; +} + bool GraphicsWindow::ToolbarDrawOrHitTest(int mx, int my, UiCanvas *canvas, Command *hitCommand, int *hitX, int *hitY) { diff --git a/src/ui.h b/src/ui.h index 39c4906f4..1e3dc91d3 100644 --- a/src/ui.h +++ b/src/ui.h @@ -826,7 +826,9 @@ class GraphicsWindow { void ToolbarDraw(UiCanvas *canvas); bool ToolbarMouseMoved(int x, int y); bool ToolbarMouseDown(int x, int y); + bool ToolbarMouseUp(int x, int y); Command toolbarHovered; + Command toolbarPressed; // This sets what gets displayed. diff --git a/src/util.h b/src/util.h index 585b5decd..660d2de39 100644 --- a/src/util.h +++ b/src/util.h @@ -40,15 +40,22 @@ #endif #define CO(v) (v).x, (v).y, (v).z - +#ifdef __ANDROID__ +#define LOG_TAG "SS" +#include +#define dbp ALOGD +#else #define dbp SolveSpace::Platform::DebugPrint +#endif namespace SolveSpace { namespace Platform { // Debug print function. +#ifndef __ANDROID__ void DebugPrint(const char *fmt, ...); +#endif } // namespace Platform From d38815f9626a5aabfd90f3cf3cbe38b023a44e1a Mon Sep 17 00:00:00 2001 From: RainbowC0 <15760207563@139.com> Date: Sun, 30 Aug 2026 11:15:36 +0800 Subject: [PATCH 2/5] Fix bugs and update README.md, src/CMakeLists.txt for Android --- CMakeLists.txt | 6 ++- README.md | 10 +++-- src/CMakeLists.txt | 17 +++++--- src/platform/guidroid.cpp | 18 +++++++++ .../com/solvespace/SolveSpaceActivity.java | 39 +++++++++++++------ src/platform/platform.cpp | 6 +-- 6 files changed, 69 insertions(+), 27 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c1738893..e3da34c0d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -90,7 +90,11 @@ if(ANDROID) endif() set(EXECUTABLE_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin) -set(LIBRARY_OUTPUT_PATH ${CMAKE_BINARY_DIR}/bin) +if(ANDROID) + 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() diff --git a/README.md b/README.md index cdbb55b7c..254e03d8f 100644 --- a/README.md +++ b/README.md @@ -363,13 +363,14 @@ First, install Android NDK and Build-Tools via sdkmanager(CLI or Android Studio) and install Java, make/ninja and cmake: ```sh -# Linux -sudo apt install default-jre cmake +# Ubuntu/Debian +sudo apt install default-jre cmake make # macOS -brew install openjdk@17 cmake +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 @@ -401,7 +402,8 @@ After that, build SolveSpace as following: ```sh cmake -Bbuild -S. -DENABLE_CLI=OFF -DENABLE_TESTS=OFF - -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$PREFIX/bin -DENABLE_OPENMP=ON + -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$PREFIX/bin + -DANDROID_ABI=arm64-v8a -DENABLE_OPENMP=ON -DENABLE_CLI=OFF -DENABLE_TESTS=OFF -DENABLE_LTO=ON -DCMAKE_BUILD_TYPE=Debug cmake --build build -j8 ``` diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 3442a78e4..f286e1c10 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -427,8 +427,15 @@ if(ENABLE_GUI) 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( @@ -436,7 +443,7 @@ if(ENABLE_GUI) COMMAND javac -cp ${ANDROID_JAR} -source 1.8 -target 1.8 -d classes ${java_src} gen/com/solvespace/R.java - COMMAND dir /s /b classes\\*.class > cls.txt + COMMAND ${LIST_CLS} > cls.txt DEPENDS ${java_src} aapt_pre WORKING_DIRECTORY ${CMAKE_BINARY_DIR} COMMENT "Compiling java" @@ -450,12 +457,10 @@ if(ENABLE_GUI) VERBATIM) add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/bin/app.apk - COMMAND ${CMAKE_COMMAND} -E make_directory ${LIBDIR} - COMMAND ${CMAKE_STRIP} -s - -o ${LIBDIR}/libsolvespace.so - bin/libsolvespace.so + COMMAND ${strip} COMMAND jar -u -f bin/res.apk classes.dex ${LIBDIR}/libsolvespace.so - COMMAND ${BUILD_TOOL}/apksigner sign --ks ${kspath} --ks-pass pass:android --v2-signing-enabled false --out bin/app.apk bin/res.apk + 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" diff --git a/src/platform/guidroid.cpp b/src/platform/guidroid.cpp index 3d0b9b2fe..e1dda4f43 100644 --- a/src/platform/guidroid.cpp +++ b/src/platform/guidroid.cpp @@ -762,6 +762,24 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass } } +extern "C" JNIEXPORT void JNICALL +Java_com_solvespace_SolveSpaceActivity_nativeSetSurface(JNIEnv *env, jclass jc, jobject surf) { + if (g_NativeWindow) { + ANativeWindow_release(g_NativeWindow); + } + g_NativeWindow = ANativeWindow_fromSurface(env, surf); + ANativeWindow_acquire(g_NativeWindow); + if (gSurface != EGL_NO_SURFACE) { + eglDestroySurface(gDisplay, gSurface); + } + eglMakeCurrent(gDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); + gSurface = EGL_NO_SURFACE; + for (auto& win : g_Windows) { + win.second->CreateSurface(); + } + Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(env, jc, g_CurrentWindow); +} + static time_t lastDown = 0; static double lastDist = 0.; #define DBL_TIMEOUT 300L diff --git a/src/platform/java/com/solvespace/SolveSpaceActivity.java b/src/platform/java/com/solvespace/SolveSpaceActivity.java index 1a84f51fe..369453c66 100644 --- a/src/platform/java/com/solvespace/SolveSpaceActivity.java +++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java @@ -42,6 +42,7 @@ public class SolveSpaceActivity extends Activity private SurfaceView sv; private PopupWindow pw; private long pmenu; + private boolean fromCreate; // Vertical scrollbar (rendered as the SurfaceView foreground). private GradientDrawable mScrollbar; @@ -96,6 +97,7 @@ protected void onCreate(Bundle savedInstanceState) sv.setOnGenericMotionListener(this); sv.setOnTouchListener(this); registerForContextMenu(sv); + fromCreate = true; } public final void sendDelayed(final long timer, long timeout) { @@ -139,7 +141,12 @@ public void surfaceChanged(SurfaceHolder p1, int p2, int p3, int p4) { @Override public void surfaceCreated(SurfaceHolder p1) { - nativeInit(p1.getSurface(), getAssets()); + if (fromCreate) { + nativeInit(p1.getSurface(), getAssets()); + fromCreate = false; + } else { + nativeSetSurface(p1.getSurface()); + } } @Override @@ -460,11 +467,7 @@ public float getDensity() { public boolean onGenericMotion(View v, MotionEvent ev) { float density = getDensity(); float dist = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); - v.postOnAnimation(new Runnable(){ - public void run() { - nativeOnMotionEvent(ev.getAction(), ev.getX()/density, ev.getY()/density, dist, ev.getButtonState(), ev.getMetaState()); - } - }); + v.postOnAnimation(new Motion(ev.getAction(), ev.getX()/density, ev.getY()/density, dist, ev.getButtonState(), ev.getMetaState())); return true; } @@ -494,11 +497,7 @@ public boolean onTouch(View v, MotionEvent ev) { } else { dist = 0.; } - v.postOnAnimation(new Runnable(){ - public void run() { - nativeOnMotionEvent(act, x, y, dist, bstat, meta); - } - }); + v.postOnAnimation(new Motion(act, x, y, dist, bstat, meta)); return true; } @@ -591,10 +590,28 @@ public static String[] getSysFonts() { private native void nativeInit(Surface suf, AssetManager amgr); private native void nativeClear(); + private native void nativeSetSurface(Surface suf); protected static native boolean nativeOnMotionEvent(int action, float x, float y, double dist, int button, int metastate); private static native boolean nativeOnKeyEvent(int keystate, int keyCode, int metastate); private static native void nativeOnWindowChanged(int winId); private static native void nativeOnEditorDone(String text); private static native void nativeOnCreateContextMenu(Menu menu, long pemnu); private static native void nativeOnContextMenuClosed(long pmenu); + + static class Motion implements Runnable { + int act, button, metastate; + float x, y; + double dist; + public Motion(int ac, float x, float y, double dis, int btn, int mt) { + act = ac; + this.x = x; + this.y = y; + dist = dis; + button = btn; + metastate = mt; + } + public void run() { + nativeOnMotionEvent(act, x, y, dist, button, metastate); + } + } } diff --git a/src/platform/platform.cpp b/src/platform/platform.cpp index 87016e14a..0d2ba067a 100644 --- a/src/platform/platform.cpp +++ b/src/platform/platform.cpp @@ -245,7 +245,7 @@ static void FindPrefix(const std::string &raw, size_t *pos) { } } #elif defined(__ANDROID__) - if(raw.compare(0, 8, "content:") == 0) { + if(strncmp("content:", raw.c_str(), 8) == 0) { // A Storage Access Framework content URI acts as its own root; there // are no path components below it that can be expanded/relativized. *pos = 8; @@ -260,13 +260,9 @@ static void FindPrefix(const std::string &raw, size_t *pos) { } bool Path::IsAbsolute() const { -#ifdef __ANDROID__ - return true; -#else size_t pos; FindPrefix(raw, &pos); return pos != std::string::npos; -#endif } // Removes one component from the end of the path. From 6b2a48feb54442072ae337d48af1faba3c7b2d7b Mon Sep 17 00:00:00 2001 From: RainbowC0 <15760207563@139.com> Date: Tue, 1 Sep 2026 02:10:36 +0800 Subject: [PATCH 3/5] Fix bugs for mouse input and keyboard input --- res/AndroidManifest.xml | 1 + src/export.cpp | 8 -- src/mouse.cpp | 5 - src/platform/guidroid.cpp | 98 +++++++++---- .../java/com/solvespace/MainHandler.java | 4 +- .../com/solvespace/SolveSpaceActivity.java | 134 ++++++++++++------ src/resource.cpp | 30 ++-- src/resource.h | 2 - src/toolbar.cpp | 27 +--- src/ui.h | 2 - 10 files changed, 184 insertions(+), 127 deletions(-) diff --git a/res/AndroidManifest.xml b/res/AndroidManifest.xml index 8e98fdf59..6c8d5e9fd 100644 --- a/res/AndroidManifest.xml +++ b/res/AndroidManifest.xml @@ -1,6 +1,7 @@ + CallObjectMethod(menu, g_Add, 0, id, 0, str); env->DeleteLocalRef(str); if (alt) { - env->CallObjectMethod(item, g_SetAlphabeticShortcut, 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)); @@ -380,6 +383,11 @@ class MenuItemImplAndroid final : public MenuItem, public Attachable { 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; @@ -506,6 +514,7 @@ class WindowImplAndroid final : public Window { double m_ScrollPage = 0.0; double m_ScrollPos = 0.0; bool m_ScrollVis = false; + TimerRef m_Inval; public: int id; @@ -595,6 +604,12 @@ class WindowImplAndroid final : public Window { void SetMenuBar(MenuBarRef newMenuBar) override { m_MenuBar = newMenuBar; + if (!m_Visible || id != g_CurrentWindow) + return; + JNIEnv *env = GetJNIEnv(); + if (env && g_Activity) { + env->CallVoidMethod(g_Activity, g_InvalidateMenu); + } } void GetContentSize(double *width, double *height) override { @@ -683,10 +698,23 @@ class WindowImplAndroid final : public Window { m_ScrollPos, m_ScrollVis); } + void Render() { + if (m_EGLContext.isCurrent()) { + if (onRender) { + onRender(); + m_EGLContext.SwapBuffers(); + } + } + } + void Invalidate() override { JNIEnv *env = GetJNIEnv(); if (env && g_Activity) { - env->CallVoidMethod(g_Activity, g_SendDelayedMethod, this, -1L); + if (!m_Inval) { + m_Inval = CreateTimer(); + m_Inval->onTimeout = std::bind(&WindowImplAndroid::Render, this); + } + m_Inval->RunAfterNextFrame(); } } @@ -698,14 +726,6 @@ class WindowImplAndroid final : public Window { } } - __inline__ void Render() { - if (m_EGLContext.isCurrent()) { - if (onRender) { - onRender(); - m_EGLContext.SwapBuffers(); - } - } - } __inline__ bool MakeCurrent() { UpdateScrollbar(); @@ -726,11 +746,28 @@ WindowRef CreateWindow(Window::Kind kind, WindowRef parentWindow) { } extern "C" JNIEXPORT void JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject surface, jobject jamgr) { +Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject surface, jobject jamgr, jobjectArray strs) { g_Activity = env->NewGlobalRef(thiz); amgr = AAssetManager_fromJava(env, jamgr); g_NativeWindow = ANativeWindow_fromSurface(env, surface); ANativeWindow_acquire(g_NativeWindow); + 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(); } @@ -851,22 +888,24 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass j } extern "C" JNIEXPORT jboolean JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeOnKeyEvent(JNIEnv *env, jclass jc, jint keyact, jint keycode, jint metastate) { +Java_com_solvespace_SolveSpaceActivity_nativeOnKeyEvent(JNIEnv *env, jclass jc, jint keyact, jint keyenc, jint metastate) { if (g_CurrentWindow >= g_NextWindowId) { return false; } + if (keyact == 0) + return false; // key char from getUnicodeChar() would failed sometimes and trigger the command::none auto win = g_Windows[g_CurrentWindow]; 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 (keycode >= AKEYCODE_F1 && keycode <= AKEYCODE_F12) { + if (keyenc < 0) { event.key = KeyboardEvent::Key::FUNCTION; - event.num = keycode - AKEYCODE_F1 + 1; + event.num = -keyenc - AKEYCODE_F1 + 1; } else { event.key = KeyboardEvent::Key::CHARACTER; - event.num = keycode; + event.chr = keyenc; } event.shiftDown = metastate & AMETA_SHIFT_ON; event.controlDown = metastate & AMETA_CTRL_ON; @@ -911,7 +950,9 @@ Java_com_solvespace_SolveSpaceActivity_onOptionsItemSelected(JNIEnv *env, jobjec } auto item = pfunc->second; if (item->onTrigger){ - item->onTrigger(); + auto timer = MenuItemImplAndroid::GetTimer(); + timer->onTimeout = item->onTrigger; + timer->RunAfter(0); return true; } return false; @@ -977,6 +1018,7 @@ JNI_OnLoad(JavaVM* vm, void* reserved) { "()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"); jclass menuClz = env->FindClass("android/view/Menu"); ssassert(menuClz != NULL, "Failed to find Menu class"); @@ -986,7 +1028,11 @@ JNI_OnLoad(JavaVM* vm, void* reserved) { jclass itemClz = env->FindClass("android/view/MenuItem"); ssassert(itemClz != NULL, "Failed to find MenuItem class"); g_GetItemId = env->GetMethodID(itemClz, "getItemId", "()I"); - g_SetAlphabeticShortcut = env->GetMethodID(itemClz, "setAlphabeticShortcut", "(C)Landroid/view/MenuItem;"); + 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;"); @@ -1109,13 +1155,21 @@ SettingsRef GetSettings() { } class TimerImplAndroid final : public Timer { - void RunAfter(unsigned int milliseconds) override { +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() { @@ -1123,12 +1177,8 @@ TimerRef CreateTimer() { } extern "C" JNIEXPORT void JNICALL -Java_com_solvespace_MainHandler_nativeRun(JNIEnv* env, jclass clazz, void *timer, jboolean frame) { - if (frame) { - ((WindowImplAndroid*)timer)->Render(); - return; - } - auto func = ((Timer*)timer)->onTimeout; +Java_com_solvespace_MainHandler_nativeRun(JNIEnv* env, jclass clazz, Timer *timer) { + auto func = timer->onTimeout; if (func) func(); } diff --git a/src/platform/java/com/solvespace/MainHandler.java b/src/platform/java/com/solvespace/MainHandler.java index 9ea61a474..0e32cb154 100644 --- a/src/platform/java/com/solvespace/MainHandler.java +++ b/src/platform/java/com/solvespace/MainHandler.java @@ -8,10 +8,10 @@ public class MainHandler extends Handler @Override public void handleMessage(Message msg) { if (msg.what == HD_NRUN) - nativeRun((Long)msg.obj, false); + nativeRun((Long)msg.obj); else if (msg.what == HD_RET) throw new RuntimeException(); } - public final static native void nativeRun(long obj, boolean frame); + 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 index 369453c66..9f4452fe6 100644 --- a/src/platform/java/com/solvespace/SolveSpaceActivity.java +++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java @@ -1,24 +1,21 @@ package com.solvespace; import android.app.*; -import android.os.*; -import android.view.*; -import android.widget.*; -import android.content.res.*; import android.content.*; -import android.provider.DocumentsContract; -import android.net.*; -import android.opengl.*; -import android.util.*; -import java.io.*; -import java.util.*; +import android.content.res.*; import android.graphics.*; import android.graphics.drawable.*; 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 java.lang.reflect.*; -import android.view.ContextMenu.*; +import android.widget.*; +import java.io.*; +import java.util.*; public class SolveSpaceActivity extends Activity implements SurfaceHolder.Callback2, ActionBar.OnNavigationListener, @@ -58,6 +55,10 @@ public class SolveSpaceActivity extends Activity // True while the user is actively dragging the scrollbar thumb. private boolean mScrollDragging; + // Mouse states + private float currX, currY; + private int mouseDown; + // Layout constants for the scrollbar. private static final int SCROLLBAR_TRACK_PADDING = 6; // dp @@ -102,9 +103,9 @@ protected void onCreate(Bundle savedInstanceState) public final void sendDelayed(final long timer, long timeout) { if (timeout < 0) { - Choreographer.getInstance().postFrameCallback(new Choreographer.FrameCallback(){ - public void doFrame(long p) { - MainHandler.nativeRun(timer, true); + sv.postOnAnimation(new Runnable(){ + public void run() { + MainHandler.nativeRun(timer); } }); } else { @@ -142,7 +143,21 @@ public void surfaceChanged(SurfaceHolder p1, int p2, int p3, int p4) { @Override public void surfaceCreated(SurfaceHolder p1) { if (fromCreate) { - nativeInit(p1.getSurface(), getAssets()); + 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) + sv.showContextMenu(currX, currY); + else + sv.showContextMenu(); } @Override @@ -218,7 +236,7 @@ public void showEditor(float x, float y, float fontHeight, float minWidth, boole // Stores the scrollbar configuration and redraws the thumb. // All geometry arguments are in normalized scrollbar units. - private void setScrollbar(double min, double max, double page, double pos, boolean visible) { + protected void setScrollbar(double min, double max, double page, double pos, boolean visible) { mScrollMin = min; mScrollMax = max; mScrollPage = page; @@ -258,14 +276,6 @@ private void layoutScrollbar() { mScrollbar.invalidateSelf(); } - // Called from input handling to feed back the adjusted scroll position. - // pos is in normalized scrollbar units (matching the C++ SCROLLBAR_UNIT). - private void setScrollbarPos(double pos) { - mScrollPos = pos; - // Notify the native side so it can react to the scroll adjustment. - nativeOnScrollbarAdjusted(pos); - } - // Callback from C++ that the scroll position was adjusted by the user. private native void nativeOnScrollbarAdjusted(double pos); @@ -466,7 +476,7 @@ public float getDensity() { @Override public boolean onGenericMotion(View v, MotionEvent ev) { float density = getDensity(); - float dist = ev.getAxisValue(MotionEvent.AXIS_VSCROLL); + float dist = ev.getAxisValue(MotionEvent.AXIS_VSCROLL)/density; v.postOnAnimation(new Motion(ev.getAction(), ev.getX()/density, ev.getY()/density, dist, ev.getButtonState(), ev.getMetaState())); return true; } @@ -483,12 +493,26 @@ public boolean onTouch(View v, MotionEvent ev) { return true; } + int act = ev.getActionMasked(), meta=ev.getMetaState(); int count = ev.getPointerCount(); - final int bstat = count == 1 ? MotionEvent.BUTTON_PRIMARY : count == 2 ?MotionEvent.BUTTON_SECONDARY : MotionEvent.BUTTON_TERTIARY; - final int act = ev.getActionMasked(), meta=ev.getMetaState(); - final float x, y; - x = ev.getX()/density; - y = ev.getY()/density; + int bstat; + float x = ev.getX(), y = ev.getY(); + if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) { + bstat = ev.getButtonState(); + // scrcpy --mouse=uhid would loss the buttonstate when action up + if (act == MotionEvent.ACTION_DOWN) + mouseDown = bstat; + else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) { + bstat = mouseDown; + mouseDown = 0; + } + currX = x; + currY = y; + } else { + bstat = count == 1 ? MotionEvent.BUTTON_PRIMARY : count == 2 ?MotionEvent.BUTTON_SECONDARY : MotionEvent.BUTTON_TERTIARY; + currX = -1.f; + currY = -1.f; + } final double dist; if (count == 2) { float x1 = ev.getX(0), x2 = ev.getX(1); @@ -497,7 +521,7 @@ public boolean onTouch(View v, MotionEvent ev) { } else { dist = 0.; } - v.postOnAnimation(new Motion(act, x, y, dist, bstat, meta)); + v.postOnAnimation(new Motion(act, x/density, y/density, dist, bstat, meta)); return true; } @@ -519,10 +543,10 @@ private void handleScrollbarTouch(MotionEvent ev, float density) { if (xInScrollbar(ev, sbLeft) && (y >= 0 && y <= height)) { mScrollDragging = true; - setScrollbarPos((y - thumbH * 0.5)*range/height); + nativeOnScrollbarAdjusted((y - thumbH * 0.5)*range/height); } } else if (act == MotionEvent.ACTION_MOVE && mScrollDragging) { - setScrollbarPos((y - thumbH * 0.5)*range/height); + nativeOnScrollbarAdjusted((y - thumbH * 0.5)*range/height); } else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) { mScrollDragging = false; } @@ -534,18 +558,16 @@ private boolean xInScrollbar(MotionEvent ev, int sbLeft) { @Override public boolean onKeyDown(int keyCode, KeyEvent ev) { - if (nativeOnKeyEvent(ev.getAction(), keyCode, ev.getMetaState())) { - return true; - } - return super.onKeyDown(keyCode, ev); + if (!super.onKeyDown(keyCode, ev)) + sv.postOnAnimation(new Key(ev)); + return true; } @Override public boolean onKeyUp(int keyCode, KeyEvent ev) { - if (nativeOnKeyEvent(ev.getAction(), keyCode, ev.getMetaState())) { - return true; - } - return super.onKeyUp(keyCode, ev); + if (!super.onKeyUp(keyCode, ev)) + sv.postOnAnimation(new Key(ev)); + return true; } public void onWinAdded(boolean isTop) { @@ -574,7 +596,7 @@ public boolean onEditorAction(TextView p1, int p2, KeyEvent p3) { return true; } - public void toast(String tst) { + private void toast(String tst) { Toast.makeText(this, tst, 0).show(); } @@ -588,11 +610,11 @@ public static String[] getSysFonts() { return arr; } - private native void nativeInit(Surface suf, AssetManager amgr); + private native void nativeInit(Surface suf, AssetManager amgr, String[] strs); private native void nativeClear(); private native void nativeSetSurface(Surface suf); protected static native boolean nativeOnMotionEvent(int action, float x, float y, double dist, int button, int metastate); - private static native boolean nativeOnKeyEvent(int keystate, int keyCode, int metastate); + protected static native boolean nativeOnKeyEvent(int keystate, int keyCode, int metastate); private static native void nativeOnWindowChanged(int winId); private static native void nativeOnEditorDone(String text); private static native void nativeOnCreateContextMenu(Menu menu, long pemnu); @@ -614,4 +636,24 @@ public void run() { nativeOnMotionEvent(act, x, y, dist, button, metastate); } } + + static class Key implements Runnable { + int act, keyenc, meta; + public Key(KeyEvent ke) { + 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(act, keyenc, meta); + } + } + + public void finish() { + super.finish(); + } } diff --git a/src/resource.cpp b/src/resource.cpp index b92d2e543..32b0e186b 100644 --- a/src/resource.cpp +++ b/src/resource.cpp @@ -3,6 +3,7 @@ // // Copyright 2016 whitequark //----------------------------------------------------------------------------- +#include "resource.h" #ifndef __ANDROID__ #include #endif @@ -29,8 +30,16 @@ std::string LoadString(const std::string &name) { return result; } -#ifndef __ANDROID__ + std::string LoadStringFromGzip(const std::string &name) { +#ifdef __ANDROID__ + // AAPT would uncompress gzip in assets when packaging, so redirect this to LoadString() + // TODO: define a macro LoadSringFromGzip redirecting to LoadString since all calls to + // this has a ".gz"-end name + const size_t siz = name.size(); + const bool endsWithGz = siz > 3 && !strncmp(".gz", name.c_str()+siz-3, 3); + return LoadString(endsWithGz ? name.substr(0, siz-3) : name); +#else size_t deflatedSize; const void *data = Platform::LoadResource(name, &deflatedSize); @@ -60,8 +69,9 @@ std::string LoadStringFromGzip(const std::string &name) { inflateEnd(&stream); return result; -} #endif +} + std::shared_ptr LoadPng(const std::string &name) { size_t size; const void *data = Platform::LoadResource(name, &size); @@ -671,13 +681,7 @@ size_t BitmapFont::GetWidth(const std::string &str) { } BitmapFont BitmapFont::Create() { - BitmapFont Font = BitmapFont::From( - #ifdef __ANDROID__ - LoadString("fonts/unifont.hex") - #else - LoadStringFromGzip("fonts/unifont.hex.gz") - #endif - ); + BitmapFont Font = BitmapFont::From(LoadStringFromGzip("fonts/unifont.hex.gz")); // Unifont doesn't have a glyph for U+0020. Font.AddGlyph(0x0020, Pixmap::Create(Pixmap::Format::RGB, 8, 16)); Font.AddGlyph(0xE000, LoadPng("fonts/private/0-check-false.png")); @@ -894,13 +898,7 @@ const VectorFont::Glyph &VectorFont::GetGlyph(char32_t codepoint) { VectorFont *VectorFont::Builtin() { static VectorFont Font; if(Font.IsEmpty()) { - Font = VectorFont::From( - #ifdef __ANDROID__ - LoadString("fonts/unicode.lff") - #else - LoadStringFromGzip("fonts/unicode.lff.gz") - #endif - ); + Font = VectorFont::From(LoadStringFromGzip("fonts/unicode.lff.gz")); } return &Font; } diff --git a/src/resource.h b/src/resource.h index 56b18408b..35ed28907 100644 --- a/src/resource.h +++ b/src/resource.h @@ -26,9 +26,7 @@ namespace Platform { } // namespace Platform std::string LoadString(const std::string &name); -#ifndef __ANDROID__ std::string LoadStringFromGzip(const std::string &name); -#endif std::shared_ptr LoadPng(const std::string &name); class Pixmap { diff --git a/src/toolbar.cpp b/src/toolbar.cpp index 54c360e4d..372b9098c 100644 --- a/src/toolbar.cpp +++ b/src/toolbar.cpp @@ -131,7 +131,7 @@ bool GraphicsWindow::ToolbarMouseMoved(int x, int y) { window->SetTooltip("", 0, 0, 0, 0); } - return withinToolbar || toolbarPressed != Command::NONE; + return withinToolbar; } bool GraphicsWindow::ToolbarMouseDown(int x, int y) { @@ -141,31 +141,14 @@ bool GraphicsWindow::ToolbarMouseDown(int x, int y) { x += ((int)width/2); y += ((int)height/2); - Command commandHit; - bool withinToolbar = ToolbarDrawOrHitTest(x, y, NULL, &commandHit, NULL, NULL); - if (commandHit != toolbarPressed) { - toolbarPressed = commandHit; - } - if (commandHit != toolbarHovered) { - toolbarHovered = commandHit; - Invalidate(); + Command hitCommand; + bool withinToolbar = ToolbarDrawOrHitTest(x, y, NULL, &hitCommand, NULL, NULL); + if(hitCommand != Command::NONE) { + SS.GW.ActivateCommand(hitCommand); } return withinToolbar; } -bool GraphicsWindow::ToolbarMouseUp(int x, int y) { - double w, h; - window->GetContentSize(&w, &h); - x += ((int)w/2); - y += ((int)h/2); - Command commandHit; - if (ToolbarDrawOrHitTest(x, y, NULL, &commandHit, NULL, NULL) && commandHit == toolbarPressed && commandHit != Command::NONE) { - ActivateCommand(commandHit); - } - toolbarPressed = Command::NONE; - return false; -} - bool GraphicsWindow::ToolbarDrawOrHitTest(int mx, int my, UiCanvas *canvas, Command *hitCommand, int *hitX, int *hitY) { diff --git a/src/ui.h b/src/ui.h index 1e3dc91d3..39c4906f4 100644 --- a/src/ui.h +++ b/src/ui.h @@ -826,9 +826,7 @@ class GraphicsWindow { void ToolbarDraw(UiCanvas *canvas); bool ToolbarMouseMoved(int x, int y); bool ToolbarMouseDown(int x, int y); - bool ToolbarMouseUp(int x, int y); Command toolbarHovered; - Command toolbarPressed; // This sets what gets displayed. From 9c0b7a1238917495c6097be29e69c38b24aa9867 Mon Sep 17 00:00:00 2001 From: RainbowC0 <15760207563@139.com> Date: Tue, 1 Sep 2026 20:19:57 +0800 Subject: [PATCH 4/5] Gesture enhancement & some fixtures for Android Double-click moving for orbiting Fix FileName() Fix CMakeList.txt for Termux --- CMakeLists.txt | 7 +++++ README.md | 14 ++++----- src/CMakeLists.txt | 5 ---- src/platform/guidroid.cpp | 30 +++++++++++++++++-- .../com/solvespace/SolveSpaceActivity.java | 19 ++++++++++-- src/platform/platform.cpp | 17 +++-------- src/platform/platform.h | 1 + 7 files changed, 64 insertions(+), 29 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index e3da34c0d..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) @@ -87,10 +89,15 @@ if(ANDROID) 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) 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) diff --git a/README.md b/README.md index 254e03d8f..0f29a0dd4 100644 --- a/README.md +++ b/README.md @@ -354,7 +354,7 @@ ninja 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 submodules. +> Android port doesn't need extlib/angle, extlib/cairo, extlib/pixman, extlib/zlib submodules. ### Building with Android NDK @@ -381,7 +381,7 @@ 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_CLI=OFF -DENABLE_TESTS=OFF + -DANDROID_ABI=arm64-v8a -DANDROID_PLATFORM=android-21 -DENABLE_LTO=ON -DENABLE_OPENMP=ON -DCMAKE_BUILD_TYPE=Debug cmake --build build -j8 ``` @@ -394,18 +394,18 @@ This will build an app.apk in the `build/bin` directory, and then install it wit First, install tools for building apk: ```sh -pkg i openjdk-21 d8 aapt apksigner cmake make clang eigen +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. -DENABLE_CLI=OFF -DENABLE_TESTS=OFF +cmake -Bbuild -S. -DANDROID_JAR=/path/to/android.jar -DBUILD_TOOL=$PREFIX/bin - -DANDROID_ABI=arm64-v8a -DENABLE_OPENMP=ON - -DENABLE_CLI=OFF -DENABLE_TESTS=OFF -DENABLE_LTO=ON -DCMAKE_BUILD_TYPE=Debug -cmake --build build -j8 + -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`. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f286e1c10..3227730bf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -22,11 +22,6 @@ if(USE_QT_GUI) qt_add_resources(RESOURCE_FILES ../res/qt-resources.qrc) endif() -if(ANDROID) - set_property(TARGET dxfrw PROPERTY - POSITION_INDEPENDENT_CODE ON) -endif() - # solvespace dependencies add_library(slvs_deps INTERFACE) target_include_directories(slvs_deps SYSTEM INTERFACE diff --git a/src/platform/guidroid.cpp b/src/platform/guidroid.cpp index 9105350d3..93f87350c 100644 --- a/src/platform/guidroid.cpp +++ b/src/platform/guidroid.cpp @@ -23,7 +23,6 @@ #include "gui.h" #include "solvespace.h" -#include "ui.h" namespace SolveSpace { namespace Platform { @@ -64,6 +63,7 @@ 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; @@ -174,6 +174,20 @@ std::string AndroidContentKey(const char *uri) { 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 //----------------------------------------------------------------------------- @@ -819,6 +833,7 @@ Java_com_solvespace_SolveSpaceActivity_nativeSetSurface(JNIEnv *env, jclass jc, static time_t lastDown = 0; static double lastDist = 0.; +static bool doubleClicked = false; #define DBL_TIMEOUT 300L extern "C" JNIEXPORT jboolean JNICALL @@ -830,12 +845,19 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass j 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; - event.type = tm - lastDown < DBL_TIMEOUT ? MouseEvent::Type::DBL_PRESS : MouseEvent::Type::PRESS; + if (tm - lastDown < DBL_TIMEOUT) { + event.type = MouseEvent::Type::DBL_PRESS; + doubleClicked = true; + } else { + event.type = MouseEvent::Type::PRESS; + } lastDown = tm; lastDist = dist; break; @@ -855,6 +877,9 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass j 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: @@ -1019,6 +1044,7 @@ JNI_OnLoad(JavaVM* vm, void* reserved) { 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"); diff --git a/src/platform/java/com/solvespace/SolveSpaceActivity.java b/src/platform/java/com/solvespace/SolveSpaceActivity.java index 9f4452fe6..c3a5bb6d2 100644 --- a/src/platform/java/com/solvespace/SolveSpaceActivity.java +++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java @@ -3,6 +3,7 @@ import android.app.*; import android.content.*; import android.content.res.*; +import android.database.*; import android.graphics.*; import android.graphics.drawable.*; import android.graphics.fonts.*; @@ -373,10 +374,11 @@ public String showFileDialog(boolean isSave, String title, String[] exts) { String fnam = fname; if (fnam == null) return null; // Apply filters - int pt = fnam.lastIndexOf("."); + String name = fileName(fnam); + int pt = name.lastIndexOf("."); int l = exts == null ? 0 : exts.length; if (pt >= 0 && l > 0) { - String ext = fnam.substring(pt+1, fnam.length()); + String ext = name.substring(pt+1, name.length()); int i=0; for (; i 8 && !fileName.compare(0, 8, "content:")) { - SEPARATOR = "%2F"; - } else { - SEPARATOR = "/"; - } - const size_t siz = SEPARATOR.size(); + if (fileName.compare(0, 8, "content:") == 0) + return DroidFileName(fileName.c_str()); #else constexpr size_t siz = 1; -#endif size_t slash = fileName.rfind(SEPARATOR); - if(slash != std::string::npos -#ifdef __ANDROID__ - ||(siz==3 && (slash=fileName.rfind("%3A")) != std::string::npos) -#endif - ) { + if(slash != std::string::npos) { fileName = fileName.substr(slash + siz); } return fileName; +#endif } std::string Path::FileStem() const { diff --git a/src/platform/platform.h b/src/platform/platform.h index ecedd5ebc..13f2e9f05 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -100,6 +100,7 @@ std::string AndroidInternalStoragePath(); // Maps a content URI to a filesystem-safe name (used as a key for // autosave/backup companions that must live in private storage). std::string AndroidContentKey(const char *uri); +std::string DroidFileName(const char *uri); #endif } // namespace Platform From 3b40a3cb1a9bf9209a20a094dc3cd4940337d2f1 Mon Sep 17 00:00:00 2001 From: RainbowC0 <15760207563@139.com> Date: Sun, 6 Sep 2026 21:15:11 +0800 Subject: [PATCH 5/5] Support side text window --- res/android/layout/main.xml | 23 ++ res/android/values/ids.xml | 3 + src/platform/guidroid.cpp | 199 +++++------- .../com/solvespace/SolveSpaceActivity.java | 302 ++++++++++-------- src/platform/platform.cpp | 3 +- 5 files changed, 280 insertions(+), 250 deletions(-) create mode 100644 res/android/layout/main.xml create mode 100644 res/android/values/ids.xml 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/src/platform/guidroid.cpp b/src/platform/guidroid.cpp index 93f87350c..e839a0742 100644 --- a/src/platform/guidroid.cpp +++ b/src/platform/guidroid.cpp @@ -87,9 +87,7 @@ static jmethodID g_PutFloat = nullptr; static jmethodID g_PutString = nullptr; static jmethodID g_Commit = nullptr; -static ANativeWindow *g_NativeWindow = nullptr; static EGLDisplay gDisplay = EGL_NO_DISPLAY; -static EGLSurface gSurface = EGL_NO_SURFACE; JNIEnv* GetJNIEnv() { ssassert(g_JavaVM != NULL, "g_JavaVM is NULL"); @@ -195,11 +193,15 @@ std::string DroidFileName(const char *uri) { class EGLContextManager { private: EGLContext m_Context; + EGLSurface m_Surf = EGL_NO_SURFACE; static EGLConfig m_Config; public: - EGLContextManager() : - m_Context(EGL_NO_CONTEXT) {} + ANativeWindow *m_NativeWindow; + EGLContextManager() : + m_Context(EGL_NO_CONTEXT), + m_Surf(EGL_NO_SURFACE), + m_NativeWindow(nullptr) {} bool Initialize() { if (gDisplay == EGL_NO_DISPLAY) { @@ -258,15 +260,14 @@ class EGLContextManager { } bool CreateSurface() { - if (!g_NativeWindow) return false; - gSurface = eglGetCurrentSurface(EGL_DRAW); - if (gSurface == EGL_NO_SURFACE) { - gSurface = eglCreateWindowSurface(gDisplay, m_Config, (EGLNativeWindowType)g_NativeWindow, NULL); - dbp("create surface"); - } else dbp("surf %p", gSurface); + 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, gSurface, gSurface, m_Context)) { + if (!eglMakeCurrent(gDisplay, m_Surf, m_Surf, m_Context)) { ALOGE("Failed to make EGL context current"); return false; } @@ -277,9 +278,9 @@ class EGLContextManager { void DestroySurface() { if (gDisplay != EGL_NO_DISPLAY) { eglMakeCurrent(gDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - if (gSurface != EGL_NO_SURFACE) { - eglDestroySurface(gDisplay, gSurface); - gSurface = EGL_NO_SURFACE; + if (m_Surf != EGL_NO_SURFACE) { + eglDestroySurface(gDisplay, m_Surf); + m_Surf = EGL_NO_SURFACE; } } } @@ -294,20 +295,17 @@ class EGLContextManager { eglTerminate(gDisplay); gDisplay = EGL_NO_DISPLAY; } + ANativeWindow_release(m_NativeWindow); } - void SwapBuffers() { - if (gDisplay != EGL_NO_DISPLAY && gSurface != EGL_NO_SURFACE) { - eglSwapBuffers(gDisplay, gSurface); + __inline__ void SwapBuffers() { + if (gDisplay != EGL_NO_DISPLAY && m_Surf != EGL_NO_SURFACE) { + eglSwapBuffers(gDisplay, m_Surf); } } - bool MakeCurrent() { - return eglMakeCurrent(gDisplay, gSurface, gSurface, m_Context); - } - - bool isCurrent() const { - return eglGetCurrentContext() == m_Context; + __inline__ bool MakeCurrent() { + return eglMakeCurrent(gDisplay, m_Surf, m_Surf, m_Context); } }; @@ -409,7 +407,6 @@ 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'; @@ -513,35 +510,39 @@ MenuBarRef GetOrCreateMainMenu(bool *unique) { //----------------------------------------------------------------------------- // Global window map (Android typically has one main window) static int g_NextWindowId = 0; -static int g_CurrentWindow = 0; extern "C" JNIEXPORT void JNICALL Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId); class WindowImplAndroid final : public Window { private: - EGLContextManager m_EGLContext; bool m_Visible; bool m_FullScreen; std::string m_Title; MenuBarRef m_MenuBar; - double m_ScrollMin = 0.0; - double m_ScrollMax = 1.0; - double m_ScrollPage = 0.0; - double m_ScrollPos = 0.0; + 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) { - env->CallVoidMethod(g_Activity, g_OnWinAdded, kind == Kind::TOPLEVEL); + 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(); } - m_EGLContext.Initialize(); + g_NextWindowId++; } ~WindowImplAndroid() override { @@ -558,12 +559,7 @@ class WindowImplAndroid final : public Window { JNIEnv* env = GetJNIEnv(); if (!env || !g_Activity) return 1.0; - jmethodID getDensity = g_GetDensity; - if (getDensity) { - jfloat density = env->CallFloatMethod(g_Activity, getDensity); - return density; - } - return 1.0; + return env->CallFloatMethod(g_Activity, g_GetDensity); } double GetPixelDensity() override { @@ -571,7 +567,7 @@ class WindowImplAndroid final : public Window { } bool IsVisible() override { - return m_Visible && g_NativeWindow != nullptr; + return m_Visible; } void SetVisible(bool visible) override { @@ -581,9 +577,6 @@ class WindowImplAndroid final : public Window { void Focus() override { // Android windows are always focused when visible - JNIEnv *env = GetJNIEnv(); - if (env) - Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(env, env->GetObjectClass(g_Activity), id); } bool IsFullScreen() override { @@ -605,20 +598,18 @@ class WindowImplAndroid final : public Window { void SetTitle(const std::string &title) override { m_Title = title; + if (id != 0) return; JNIEnv* env = GetJNIEnv(); if (env && g_Activity) { - jmethodID setTitleMethod = g_SetTitle; - if (setTitleMethod) { - jstring jTitle = env->NewStringUTF(title.c_str()); - env->CallVoidMethod(g_Activity, setTitleMethod, id, jTitle); - env->DeleteLocalRef(jTitle); - } + 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 != g_CurrentWindow) + if (!m_Visible || id != 0) return; JNIEnv *env = GetJNIEnv(); if (env && g_Activity) { @@ -628,8 +619,8 @@ class WindowImplAndroid final : public Window { void GetContentSize(double *width, double *height) override { float pixelRatio = GetDevicePixelRatio(); - *width = ANativeWindow_getWidth(g_NativeWindow) / pixelRatio; - *height = ANativeWindow_getHeight(g_NativeWindow) / pixelRatio; + *width = ANativeWindow_getWidth(m_EGLContext.m_NativeWindow) / pixelRatio; + *height = ANativeWindow_getHeight(m_EGLContext.m_NativeWindow) / pixelRatio; } void SetMinContentSize(double width, double height) override { @@ -667,7 +658,7 @@ class WindowImplAndroid final : public Window { if (!(env && g_Activity)) return; jstring jText = env->NewStringUTF(text.c_str()); env->CallVoidMethod(g_Activity, g_ShowEditorMethod, - (float)x, (float)y, (float)fontHeight, (float)minWidth, isMonospace, jText); + id, (float)x, (float)y, (float)fontHeight, (float)minWidth, isMonospace, jText); env->DeleteLocalRef(jText); } @@ -680,16 +671,14 @@ class WindowImplAndroid final : public Window { void SetScrollbarVisible(bool visible) override { m_ScrollVis = visible; - if (id == g_CurrentWindow) - UpdateScrollbar(); + UpdateScrollbar(); } void ConfigureScrollbar(double min, double max, double pageSize) override { m_ScrollMin = min; m_ScrollMax = max; m_ScrollPage = pageSize; - if (id == g_CurrentWindow) - UpdateScrollbar(); + UpdateScrollbar(); } double GetScrollbarPosition() override { @@ -698,8 +687,7 @@ class WindowImplAndroid final : public Window { void SetScrollbarPosition(double pos) override { m_ScrollPos = pos; - if (id == g_CurrentWindow) - UpdateScrollbar(); + UpdateScrollbar(); } // Pushes the current scrollbar geometry to the Java layer, which draws the @@ -708,16 +696,15 @@ class WindowImplAndroid final : public Window { JNIEnv *env = GetJNIEnv(); if (!(env && g_Activity) || !g_SetScrollbar) return; env->CallVoidMethod(g_Activity, g_SetScrollbar, - m_ScrollMin, m_ScrollMax, m_ScrollPage, + id, m_ScrollMin, m_ScrollMax, m_ScrollPage, m_ScrollPos, m_ScrollVis); } void Render() { - if (m_EGLContext.isCurrent()) { - if (onRender) { - onRender(); - m_EGLContext.SwapBuffers(); - } + if (onRender) { + m_EGLContext.MakeCurrent(); + onRender(); + m_EGLContext.SwapBuffers(); } } @@ -740,7 +727,6 @@ class WindowImplAndroid final : public Window { } } - __inline__ bool MakeCurrent() { UpdateScrollbar(); return m_EGLContext.MakeCurrent(); @@ -754,17 +740,14 @@ static std::unordered_map> g_Windows; WindowRef CreateWindow(Window::Kind kind, WindowRef parentWindow) { auto window = std::make_shared(kind); - window->id = g_NextWindowId; - g_Windows[g_NextWindowId++] = window; + g_Windows[window->id] = window; return window; } extern "C" JNIEXPORT void JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject surface, jobject jamgr, jobjectArray strs) { +Java_com_solvespace_SolveSpaceActivity_nativeInit(JNIEnv* env, jobject thiz, jobject jamgr, jobjectArray strs) { g_Activity = env->NewGlobalRef(thiz); amgr = AAssetManager_fromJava(env, jamgr); - g_NativeWindow = ANativeWindow_fromSurface(env, surface); - ANativeWindow_acquire(g_NativeWindow); jsize siz = 0, i = 0; if (strs && (siz=env->GetArrayLength(strs))) { for (i=0; iDeleteGlobalRef(g_Activity); g_Activity = nullptr; - if (g_NativeWindow) { - ANativeWindow_release(g_NativeWindow); - g_NativeWindow = nullptr; - } amgr = nullptr; } void JNICALL Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(JNIEnv *env, jclass jc, jint winId) { if (winId < g_NextWindowId) { - g_CurrentWindow = winId; - auto win = g_Windows[winId]; - win->MakeCurrent(); - win->Invalidate(); + g_Windows[winId]->Invalidate(); } } extern "C" JNIEXPORT void JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeSetSurface(JNIEnv *env, jclass jc, jobject surf) { - if (g_NativeWindow) { - ANativeWindow_release(g_NativeWindow); - } - g_NativeWindow = ANativeWindow_fromSurface(env, surf); - ANativeWindow_acquire(g_NativeWindow); - if (gSurface != EGL_NO_SURFACE) { - eglDestroySurface(gDisplay, gSurface); - } - eglMakeCurrent(gDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT); - gSurface = EGL_NO_SURFACE; - for (auto& win : g_Windows) { - win.second->CreateSurface(); - } - Java_com_solvespace_SolveSpaceActivity_nativeOnWindowChanged(env, jc, g_CurrentWindow); +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; @@ -837,11 +809,8 @@ static bool doubleClicked = false; #define DBL_TIMEOUT 300L extern "C" JNIEXPORT jboolean JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass jc, jint act, jfloat x, jfloat y, jdouble dist, jint button, jint state) { - if (g_CurrentWindow >= g_NextWindowId) { - return false; - } - auto win = g_Windows[g_CurrentWindow]; +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; @@ -913,13 +882,10 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnMotionEvent(JNIEnv *env, jclass j } extern "C" JNIEXPORT jboolean JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeOnKeyEvent(JNIEnv *env, jclass jc, jint keyact, jint keyenc, jint metastate) { - if (g_CurrentWindow >= g_NextWindowId) { - return false; - } +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[g_CurrentWindow]; + auto win = g_Windows[wid]; KeyboardEvent event = {}; if (keyact == AKEY_EVENT_ACTION_DOWN) event.type = KeyboardEvent::Type::PRESS; @@ -938,18 +904,15 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnKeyEvent(JNIEnv *env, jclass jc, } extern "C" JNIEXPORT void JNICALL -Java_com_solvespace_SolveSpaceActivity_nativeOnEditorDone(JNIEnv *env, jclass clz, jstring str) { - auto win = g_Windows[g_CurrentWindow]; +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, jdouble pos) { - if (g_CurrentWindow >= g_NextWindowId) { - return; - } - auto win = g_Windows[g_CurrentWindow]; +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); @@ -958,11 +921,9 @@ Java_com_solvespace_SolveSpaceActivity_nativeOnScrollbarAdjusted(JNIEnv *env, jo extern "C" JNIEXPORT jboolean JNICALL Java_com_solvespace_SolveSpaceActivity_onCreateOptionsMenu(JNIEnv *env, jobject thiz, jobject menu) { - int currWin = g_CurrentWindow; - if (currWin >= g_NextWindowId) - return false; + if (g_NextWindowId == 0) return false; env->CallVoidMethod(menu, g_Clear); - g_Windows[currWin]->InvalidateMenu(env, menu); + g_Windows[0]->InvalidateMenu(env, menu); return true; } @@ -1020,7 +981,7 @@ JNI_OnLoad(JavaVM* vm, void* reserved) { // Cache method IDs g_IsEditorVisibleMethod = env->GetMethodID(activityClass, "isEditorVisible", "()Z"); g_ShowEditorMethod = env->GetMethodID(activityClass, "showEditor", - "(FFFFZLjava/lang/String;)V"); + "(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"); @@ -1029,11 +990,11 @@ JNI_OnLoad(JavaVM* vm, void* reserved) { g_SendDelayedMethod = env->GetMethodID(activityClass, "sendDelayed", "(JJ)V"); g_SetTitle = env->GetMethodID(activityClass, "setWinTitle", - "(ILjava/lang/String;)V"); - g_OnWinAdded = env->GetMethodID(activityClass, "onWinAdded", "(Z)V"); + "(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", "(DDDDZ)V"); + 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"); diff --git a/src/platform/java/com/solvespace/SolveSpaceActivity.java b/src/platform/java/com/solvespace/SolveSpaceActivity.java index c3a5bb6d2..32c2f139c 100644 --- a/src/platform/java/com/solvespace/SolveSpaceActivity.java +++ b/src/platform/java/com/solvespace/SolveSpaceActivity.java @@ -6,6 +6,7 @@ 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.*; @@ -22,7 +23,7 @@ public class SolveSpaceActivity extends Activity implements SurfaceHolder.Callback2, ActionBar.OnNavigationListener, View.OnTouchListener, View.OnGenericMotionListener, TextView.OnEditorActionListener, DialogInterface.OnCancelListener, DialogInterface.OnClickListener, -PopupWindow.OnDismissListener, DialogInterface.OnDismissListener +PopupWindow.OnDismissListener, DialogInterface.OnDismissListener, View.OnClickListener { private final static String TAG = "SolveSpace"; private final static int @@ -35,42 +36,43 @@ public class SolveSpaceActivity extends Activity } private Handler hand = new MainHandler(); - private ArrayList wins; - private ArrayAdapter sa; - private SurfaceView sv; + private SurfaceView[] svs; private PopupWindow pw; private long pmenu; - private boolean fromCreate; + private int inited; // Vertical scrollbar (rendered as the SurfaceView foreground). - private GradientDrawable mScrollbar; - private int mScrollbarWidth = 6; - private int mViewWidth; - private int mViewHeight; - // Scrollbar geometry in normalized units (matching the C++ side). - private double mScrollMin; - private double mScrollMax; - private double mScrollPage; - private double mScrollPos; - private boolean mScrollVisible; + 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 = 6; // dp + 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); - super.onCreate(savedInstanceState); - SurfaceView sv = new SurfaceView(this); - this.sv = sv; + 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++ @@ -79,32 +81,29 @@ protected void onCreate(Bundle savedInstanceState) float density = getResources().getDisplayMetrics().density; int sbWidth = (int)(SCROLLBAR_TRACK_PADDING * density); mScrollbarWidth = sbWidth; - GradientDrawable thumb = new GradientDrawable(); - thumb.setColor(0x80ffffff); - mScrollbar = thumb; + 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); - setContentView(sv, new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT - )); - wins = new ArrayList<>(); - sa = new ArrayAdapter(this, android.R.layout.simple_spinner_item, wins); - sa.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); - ActionBar act = getActionBar(); - act.setDisplayShowTitleEnabled(false); - act.setNavigationMode(ActionBar.NAVIGATION_MODE_LIST); - act.setListNavigationCallbacks(sa, this); sv.getHolder().addCallback(this); + tv.getHolder().addCallback(this); sv.setOnGenericMotionListener(this); + tv.setOnGenericMotionListener(this); sv.setOnTouchListener(this); + tv.setOnTouchListener(this); registerForContextMenu(sv); - fromCreate = true; + inited = 2; } public final void sendDelayed(final long timer, long timeout) { if (timeout < 0) { - sv.postOnAnimation(new Runnable(){ + svs[0].postOnAnimation(new Runnable(){ public void run() { MainHandler.nativeRun(timer); } @@ -119,12 +118,40 @@ public void run() { public native boolean onCreateOptionsMenu(Menu menu); private void notifyOrientation(Configuration conf) { - View v = getWindow().getDecorView(); - v.setSystemUiVisibility( - conf.orientation == Configuration.ORIENTATION_LANDSCAPE ? + 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) - : 0 ); + 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 @@ -135,15 +162,16 @@ public void onConfigurationChanged(Configuration newConfig) } @Override - public void surfaceChanged(SurfaceHolder p1, int p2, int p3, int p4) { - mViewWidth = p3; - mViewHeight = p4; - nativeOnWindowChanged(getActionBar().getSelectedNavigationIndex()); + 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 (fromCreate) { + if (inited >= 0) + inited--; + if (inited == 0) { String[] lcs; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { LocaleList lclst = getResources().getConfiguration().getLocales(); @@ -158,10 +186,14 @@ public void surfaceCreated(SurfaceHolder p1) { Locale lc = Locale.getDefault(); lcs[0] = lc.getLanguage()+"_"+lc.getCountry(); } - nativeInit(p1.getSurface(), getAssets(), lcs); - fromCreate = false; - } else { - nativeSetSurface(p1.getSurface()); + nativeInit(getAssets(), lcs); + } else if (inited < 0) { + for (int i=0;i= 0 && currY >= 0) - sv.showContextMenu(currX, currY); + v.showContextMenu(currX, currY); else - sv.showContextMenu(); + v.showContextMenu(); } @Override @@ -204,7 +237,7 @@ protected void onDestroy() { super.onDestroy(); } - public void showEditor(float x, float y, float fontHeight, float minWidth, boolean isMono, String text) { + 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); @@ -212,6 +245,7 @@ public void showEditor(float x, float y, float fontHeight, float minWidth, boole ed.setOnEditorActionListener(this); ed.setTextColor(0xff000000); ed.setFocusable(true); + ed.setTag(wid); PopupWindow pw = new PopupWindow( ed, ViewGroup.LayoutParams.WRAP_CONTENT, @@ -227,9 +261,11 @@ public void showEditor(float x, float y, float fontHeight, float minWidth, boole pw.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); pw.setOnDismissListener(this); float density = getDensity(); - int xi = (int)(x * density)-ed.getPaddingLeft(); - int yi = (int)(y * density)+getActionBar().getHeight(); - pw.showAtLocation(getWindow().getDecorView(), Gravity.TOP|Gravity.LEFT, xi, yi); + 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(); } @@ -237,34 +273,28 @@ public void showEditor(float x, float y, float fontHeight, float minWidth, boole // Stores the scrollbar configuration and redraws the thumb. // All geometry arguments are in normalized scrollbar units. - protected void setScrollbar(double min, double max, double page, double pos, boolean visible) { - mScrollMin = min; - mScrollMax = max; - mScrollPage = page; - mScrollPos = pos; - mScrollVisible = visible; - layoutScrollbar(); - } - - // Updates the LayerDrawable item (thumb) geometry: its horizontal width - // (via the right inset, keeping it pinned to the right gravity), and its - // vertical start/length via top/bottom insets. Hidden when not visible. - private void layoutScrollbar() { - if (mScrollbar == null) return; - if (!mScrollVisible || mViewHeight <= 0 || - mScrollMax <= mScrollMin) { - mScrollbar.setAlpha(0); + 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; } - mScrollbar.setAlpha(0x80); - - int height = mViewHeight; - double range = Math.max(1e-6, mScrollMax - mScrollMin); + double range = Math.max(1e-6, max - min); // Thumb length is proportional to the page size within the range. - int thumbH = (int)(mScrollPage / (mScrollMax - mScrollMin) * height); + int thumbH = (int)(page / (max - min) * height); - double ratio = (mScrollPos - mScrollMin) / range; + double ratio = (pos - min) / range; if (ratio < 0) ratio = 0; if (ratio > 1) ratio = 1; int top = (int)(ratio * height); @@ -273,18 +303,20 @@ private void layoutScrollbar() { // Pin the thumb to the right edge; its width is controlled by the // right inset relative to the right gravity of the layer. - mScrollbar.setBounds(mViewWidth-mScrollbarWidth, top, mViewWidth, bottom); - mScrollbar.invalidateSelf(); + 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(double pos); + private native void nativeOnScrollbarAdjusted(int wid, double pos); @Override public void onDismiss() { PopupWindow pw = this.pw; if (pw != null) { - nativeOnEditorDone(((EditText)pw.getContentView()).getText().toString()); + EditText ed = (EditText)pw.getContentView(); + nativeOnEditorDone((Integer)ed.getTag(), ed.getText().toString()); } } @@ -492,7 +524,7 @@ public float getDensity() { public boolean onGenericMotion(View v, MotionEvent ev) { float density = getDensity(); float dist = ev.getAxisValue(MotionEvent.AXIS_VSCROLL)/density; - v.postOnAnimation(new Motion(ev.getAction(), ev.getX()/density, ev.getY()/density, dist, ev.getButtonState(), ev.getMetaState())); + v.postOnAnimation(new Motion(((Integer)v.getTag()), ev.getAction(), ev.getX()/density, ev.getY()/density, dist, ev.getButtonState(), ev.getMetaState())); return true; } @@ -502,7 +534,7 @@ public boolean onGenericMotion(View v, MotionEvent ev) { @Override public boolean onTouch(View v, MotionEvent ev) { float density = getDensity(); - handleScrollbarTouch(ev, density); + handleScrollbarTouch(v, ev, density); if (mScrollDragging) { return true; @@ -515,11 +547,13 @@ public boolean onTouch(View v, MotionEvent ev) { if (ev.isFromSource(InputDevice.SOURCE_MOUSE)) { bstat = ev.getButtonState(); // scrcpy --mouse=uhid would loss the buttonstate when action up - if (act == MotionEvent.ACTION_DOWN) + if (act == MotionEvent.ACTION_DOWN) { mouseDown = bstat; - else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) { + currWid = (Integer)v.getTag(); + } else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) { bstat = mouseDown; mouseDown = 0; + currWid = 0; } currX = x; currY = y; @@ -536,78 +570,69 @@ else if (act == MotionEvent.ACTION_UP || act == MotionEvent.ACTION_CANCEL) { } else { dist = 0.; } - v.postOnAnimation(new Motion(act, x/density, y/density, dist, bstat, meta)); + v.postOnAnimation(new Motion(((Integer)v.getTag()), act, x/density, y/density, dist, bstat, meta)); return true; } // Adjusts the scroll position when the user touches or drags the scrollbar // thumb/track, and refreshes the drawn thumb to follow the finger. - private void handleScrollbarTouch(MotionEvent ev, float density) { - double height = mViewHeight/density; - if (!mScrollVisible || height <= 0) return; + private void handleScrollbarTouch(View v, MotionEvent ev, float density) { + int wid = (Integer)v.getTag(); + float height = v.getHeight()/density; + int width = v.getWidth(); + ScrollHolder holder = (ScrollHolder)v.getTag(R.id.scroll); + if (!holder.visible || height <= 0) return; float y = ev.getY() / density; - double range = Math.max(1e-6, mScrollMax - mScrollMin); - double scale = mScrollPage / (mScrollMax - mScrollMin); + double range = Math.max(1e-6, holder.max - holder.min); + double scale = holder.page / (holder.max - holder.min); int thumbH = (int)(height * scale); int act = ev.getActionMasked(); if (act == MotionEvent.ACTION_DOWN) { // Begin dragging if the touch is on the thumb or in the scrollbar // track area on the right edge. - int sbLeft = mViewWidth - mScrollbarWidth - (int)(SCROLLBAR_TRACK_PADDING*density); - if (xInScrollbar(ev, sbLeft) && + int sbLeft = width - mScrollbarWidth - (int)(SCROLLBAR_TRACK_PADDING*density); + if (xInScrollbar(ev, sbLeft, width) && (y >= 0 && y <= height)) { mScrollDragging = true; - nativeOnScrollbarAdjusted((y - thumbH * 0.5)*range/height); + nativeOnScrollbarAdjusted(wid, (y - thumbH * 0.5)*range/height); } } else if (act == MotionEvent.ACTION_MOVE && mScrollDragging) { - nativeOnScrollbarAdjusted((y - thumbH * 0.5)*range/height); + 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) { - return ev.getX() >= sbLeft && ev.getX() <= mViewWidth; + 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)) - sv.postOnAnimation(new Key(ev)); + svs[0].postOnAnimation(new Key(0, ev)); return true; } @Override public boolean onKeyUp(int keyCode, KeyEvent ev) { if (!super.onKeyUp(keyCode, ev)) - sv.postOnAnimation(new Key(ev)); + svs[0].postOnAnimation(new Key(0, ev)); return true; } - public void onWinAdded(boolean isTop) { - int count = sa.getCount(); - sa.add("#"+count); - sa.notifyDataSetChanged(); - if (isTop) - getActionBar().setSelectedNavigationItem(count); + public Surface onWinAdded(int wid) { + return svs[wid].getHolder().getSurface(); } - public void setWinTitle(int winId, String title) { - wins.set(winId, title); - sa.notifyDataSetChanged(); - } - - @Override - public boolean onNavigationItemSelected(int p1, long p2) { - nativeOnWindowChanged(p1); - invalidateOptionsMenu(); - return true; + public void setWinTitle(String title) { + getActionBar().setTitle(title); } @Override public boolean onEditorAction(TextView p1, int p2, KeyEvent p3) { - nativeOnEditorDone(p1.getText().toString()); + nativeOnEditorDone((Integer)p1.getTag(), p1.getText().toString()); return true; } @@ -625,21 +650,38 @@ public static String[] getSysFonts() { return arr; } - private native void nativeInit(Surface suf, AssetManager amgr, String[] strs); + @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(Surface suf); - protected static native boolean nativeOnMotionEvent(int action, float x, float y, double dist, int button, int metastate); - protected static native boolean nativeOnKeyEvent(int keystate, int keyCode, int metastate); + 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(String text); + 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 act, button, metastate; + int id, act, button, metastate; float x, y; double dist; - public Motion(int ac, float x, float y, double dis, int btn, int mt) { + 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; @@ -648,13 +690,14 @@ public Motion(int ac, float x, float y, double dis, int btn, int mt) { metastate = mt; } public void run() { - nativeOnMotionEvent(act, x, y, dist, button, metastate); + nativeOnMotionEvent(id, act, x, y, dist, button, metastate); } } static class Key implements Runnable { - int act, keyenc, meta; - public Key(KeyEvent ke) { + 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) @@ -664,11 +707,12 @@ public Key(KeyEvent ke) { meta = ke.getMetaState(); } public void run() { - nativeOnKeyEvent(act, keyenc, meta); + nativeOnKeyEvent(id, act, keyenc, meta); } } - public void finish() { - super.finish(); + static class ScrollHolder { + float min, max, page, pos; + boolean visible; } } diff --git a/src/platform/platform.cpp b/src/platform/platform.cpp index d9b830f3c..dad98bf63 100644 --- a/src/platform/platform.cpp +++ b/src/platform/platform.cpp @@ -159,14 +159,13 @@ std::string Path::FileName() const { #ifdef __ANDROID__ if (fileName.compare(0, 8, "content:") == 0) return DroidFileName(fileName.c_str()); -#else +#endif constexpr size_t siz = 1; size_t slash = fileName.rfind(SEPARATOR); if(slash != std::string::npos) { fileName = fileName.substr(slash + siz); } return fileName; -#endif } std::string Path::FileStem() const {