diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d4f3cca95b..9b2f017c285 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -689,8 +689,8 @@ endif() if(CLIENT AND NOT(SDL2_FOUND)) message(SEND_ERROR "You must install SDL2 to compile the ${CMAKE_PROJECT_NAME} client") endif() -if(TARGET_OS STREQUAL "android" AND CLIENT AND NOT(CRYPTO_FOUND)) - message(SEND_ERROR "You must install OpenSSL to compile the ${CMAKE_PROJECT_NAME} client") +if(TARGET_OS STREQUAL "android" AND NOT(CRYPTO_FOUND)) + message(SEND_ERROR "You must install OpenSSL to compile ${CMAKE_PROJECT_NAME}") endif() if(NOT(GTEST_FOUND)) if(DOWNLOAD_GTEST) @@ -741,7 +741,7 @@ elseif(TARGET_OS STREQUAL "android") src/android/android_main.cpp ) set(PLATFORM_LIBS ${TW_ANDROID_LIBS}) - set(PLATFORM_CLIENT_LIBS ${PLATFORM_LIBS}) + set(PLATFORM_CLIENT_LIBS ${TW_ANDROID_LIBS_CLIENT}) set(PLATFORM_CLIENT_INCLUDE_DIRS) else() find_package(Notify) @@ -2782,14 +2782,25 @@ if(SERVER) ) # Target - add_executable(game-server - ${DEPS} - ${SERVER_SRC} - ${SERVER_ICON} - $ - $ - $ - ) + if(TARGET_OS STREQUAL "android") + add_library(game-server SHARED + ${DEPS} + ${SERVER_SRC} + ${SERVER_ICON} + $ + $ + $ + ) + else() + add_executable(game-server + ${DEPS} + ${SERVER_SRC} + ${SERVER_ICON} + $ + $ + $ + ) + endif() set_property(TARGET game-server PROPERTY OUTPUT_NAME ${SERVER_EXECUTABLE} ) @@ -3600,6 +3611,13 @@ foreach(target ${TARGETS_OWN}) if(CMAKE_SYSTEM_NAME STREQUAL "Emscripten") target_compile_definitions(${target} PRIVATE CONF_WEBASM) endif() + if(TARGET_OS STREQUAL "android") + if(ANDROID_PACKAGE_NAME) + target_compile_definitions(${target} PRIVATE ANDROID_PACKAGE_NAME=${ANDROID_PACKAGE_NAME}) + else() + message(FATAL_ERROR "ANDROID_PACKAGE_NAME must define the package name when compiling for Android (using underscores instead of dots, e.g. org_example_app)") + endif() + endif() endforeach() foreach(target ${TARGETS_DEP}) diff --git a/cmake/FindAndroid.cmake b/cmake/FindAndroid.cmake index 537ed1f5b03..a42a0a1f5ae 100644 --- a/cmake/FindAndroid.cmake +++ b/cmake/FindAndroid.cmake @@ -27,4 +27,16 @@ FIND_LIBRARY(ANDROID_LIBRARY_OPENSLES OpenSLES ) -set(TW_ANDROID_LIBS ${ANDROID_LIBRARY_EGL} ${ANDROID_LIBRARY_GLES1} ${ANDROID_LIBRARY_GLES2} ${ANDROID_LIBRARY_GLES3} ${ANDROID_LIBRARY_LOG} ${ANDROID_LIBRARY_ANDROID} ${ANDROID_LIBRARY_OPENSLES}) +set(TW_ANDROID_LIBS + ${ANDROID_LIBRARY_LOG} + ${ANDROID_LIBRARY_ANDROID} +) + +set(TW_ANDROID_LIBS_CLIENT + ${TW_ANDROID_LIBS} + ${ANDROID_LIBRARY_EGL} + ${ANDROID_LIBRARY_GLES1} + ${ANDROID_LIBRARY_GLES2} + ${ANDROID_LIBRARY_GLES3} + ${ANDROID_LIBRARY_OPENSLES} +) diff --git a/scripts/android/cmake_android.sh b/scripts/android/cmake_android.sh index f2720d49971..a8b7ad0ceac 100755 --- a/scripts/android/cmake_android.sh +++ b/scripts/android/cmake_android.sh @@ -153,6 +153,7 @@ function build_for_type() { -DANDROID_NDK="$ANDROID_NDK_HOME" \ -DANDROID_ABI="${2}" \ -DANDROID_ARM_NEON=TRUE \ + -DANDROID_PACKAGE_NAME="${PACKAGE_NAME//./_}" \ -DCMAKE_ANDROID_NDK="$ANDROID_NDK_HOME" \ -DCMAKE_SYSTEM_NAME=Android \ -DCMAKE_SYSTEM_VERSION="$ANDROID_API_LEVEL" \ @@ -160,7 +161,7 @@ function build_for_type() { -DCARGO_NDK_TARGET="${3}" \ -DCARGO_NDK_API="$ANDROID_API_LEVEL" \ -B"${BUILD_FOLDER}/$ANDROID_SUB_BUILD_DIR/$1" \ - -DSERVER=OFF \ + -DSERVER=ON \ -DTOOLS=OFF \ -DDEV=TRUE \ -DCMAKE_CROSSCOMPILING=ON \ @@ -170,7 +171,7 @@ function build_for_type() { cd "${BUILD_FOLDER}/$ANDROID_SUB_BUILD_DIR/$1" || exit 1 # We want word splitting # shellcheck disable=SC2086 - cmake --build . --target game-client $BUILD_FLAGS + cmake --build . --target game-client game-server $BUILD_FLAGS ) } @@ -228,6 +229,7 @@ log_info "Copying libraries..." function copy_libs() { mkdir -p "lib/$2" cp "$ANDROID_SUB_BUILD_DIR/$1/libDDNet.so" "lib/$2" || exit 1 + cp "$ANDROID_SUB_BUILD_DIR/$1/libDDNet-Server.so" "lib/$2" || exit 1 } if [[ "${ANDROID_BUILD}" == "arm" || "${ANDROID_BUILD}" == "all" ]]; then diff --git a/scripts/android/files/AndroidManifest.xml b/scripts/android/files/AndroidManifest.xml index 3106bd9a394..b22267cb723 100644 --- a/scripts/android/files/AndroidManifest.xml +++ b/scripts/android/files/AndroidManifest.xml @@ -41,6 +41,11 @@ + + + + + + + + diff --git a/scripts/android/files/build.gradle b/scripts/android/files/build.gradle index cc1a5390423..5789fe04124 100644 --- a/scripts/android/files/build.gradle +++ b/scripts/android/files/build.gradle @@ -61,6 +61,9 @@ android { lintOptions { abortOnError false } + dependencies { + implementation 'androidx.core:core:1.13.1' + } } allprojects { diff --git a/scripts/android/files/build.sh b/scripts/android/files/build.sh index 7958e7f67b1..7d0859a495a 100644 --- a/scripts/android/files/build.sh +++ b/scripts/android/files/build.sh @@ -59,7 +59,8 @@ if [ "${APK_PACKAGE_FOLDER}" != "org/ddnet/client" ]; then mv src/main/java/org/ddnet/client src/main/java/"${APK_PACKAGE_FOLDER}" fi -sed -i "s/org.ddnet.client/${APK_PACKAGE_NAME}/g" src/main/java/"${APK_PACKAGE_FOLDER}"/NativeMain.java +sed -i "s/org.ddnet.client/${APK_PACKAGE_NAME}/g" src/main/java/"${APK_PACKAGE_FOLDER}"/ClientActivity.java +sed -i "s/org.ddnet.client/${APK_PACKAGE_NAME}/g" src/main/java/"${APK_PACKAGE_FOLDER}"/ServerService.java sed -i "s/org.ddnet.client/${APK_PACKAGE_NAME}/g" proguard-rules.pro # disable hid manager for now diff --git a/scripts/android/files/java/org/ddnet/client/ClientActivity.java b/scripts/android/files/java/org/ddnet/client/ClientActivity.java new file mode 100644 index 00000000000..e364471d5aa --- /dev/null +++ b/scripts/android/files/java/org/ddnet/client/ClientActivity.java @@ -0,0 +1,127 @@ +package org.ddnet.client; + +import android.app.NativeActivity; +import android.content.*; +import android.content.pm.ActivityInfo; +import android.os.*; + +import androidx.core.content.ContextCompat; + +import org.libsdl.app.SDLActivity; + +public class ClientActivity extends SDLActivity { + + private static final int COMMAND_RESTART_APP = SDLActivity.COMMAND_USER + 1; + + private String[] launchArguments = new String[0]; + + private final Object serverServiceMonitor = new Object(); + private Messenger serverServiceMessenger = null; + private final ServiceConnection serverServiceConnection = new ServiceConnection() { + @Override + public void onServiceConnected(ComponentName name, IBinder service) { + synchronized(serverServiceMonitor) { + serverServiceMessenger = new Messenger(service); + } + } + + @Override + public void onServiceDisconnected(ComponentName name) { + synchronized(serverServiceMonitor) { + serverServiceMessenger = null; + } + } + }; + + @Override + protected String[] getLibraries() { + return new String[] { + "DDNet", + }; + } + + @Override + public void onCreate(Bundle savedInstanceState) { + setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + + Intent intent = getIntent(); + if(intent != null) { + String gfxBackend = intent.getStringExtra("gfx-backend"); + if(gfxBackend != null) { + if(gfxBackend.equals("Vulkan")) { + launchArguments = new String[] {"gfx_backend Vulkan"}; + } else if(gfxBackend.equals("GLES")) { + launchArguments = new String[] {"gfx_backend GLES"}; + } + } + } + + super.onCreate(savedInstanceState); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + synchronized(serverServiceMonitor) { + if(serverServiceMessenger != null) { + unbindService(serverServiceConnection); + } + } + } + + @Override + protected String[] getArguments() { + return launchArguments; + } + + @Override + protected boolean onUnhandledMessage(int command, Object param) { + switch(command) { + case COMMAND_RESTART_APP: + restartApp(); + return true; + } + return false; + } + + private void restartApp() { + Intent restartIntent = + Intent.makeRestartActivityTask( + getPackageManager().getLaunchIntentForPackage( + getPackageName() + ).getComponent() + ); + restartIntent.setPackage(getPackageName()); + startActivity(restartIntent); + } + + // Called from native code, see android_main.cpp + public void startServer() { + synchronized(serverServiceMonitor) { + Intent startIntent = new Intent(this, ServerService.class); + ContextCompat.startForegroundService(this, startIntent); + bindService(startIntent, serverServiceConnection, 0); + } + } + + // Called from native code, see android_main.cpp + public void stopServer() { + synchronized(serverServiceMonitor) { + if(serverServiceMessenger != null) { + try { + serverServiceMessenger.send(Message.obtain(null, ServerService.MESSAGE_STOP, 0, 0)); + } catch (RemoteException e) { + // Server already stopped or connection otherwise broken + unbindService(serverServiceConnection); + } + } + } + } + + // Called from native code, see android_main.cpp + public boolean isServerRunning() { + synchronized(serverServiceMonitor) { + return serverServiceMessenger != null; + } + } +} diff --git a/scripts/android/files/java/org/ddnet/client/NativeMain.java b/scripts/android/files/java/org/ddnet/client/NativeMain.java deleted file mode 100644 index e56d4cebca8..00000000000 --- a/scripts/android/files/java/org/ddnet/client/NativeMain.java +++ /dev/null @@ -1,65 +0,0 @@ -package org.ddnet.client; - -import android.app.NativeActivity; -import org.libsdl.app.SDLActivity; -import android.os.Bundle; -import android.content.Intent; -import android.content.pm.ActivityInfo; - -public class NativeMain extends SDLActivity { - - private static final int COMMAND_RESTART_APP = SDLActivity.COMMAND_USER + 1; - - private String[] launchArguments = new String[0]; - - @Override - protected String[] getLibraries() { - return new String[] { - "DDNet", - }; - } - - @Override - public void onCreate(Bundle SavedInstanceState) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); - - Intent intent = getIntent(); - if(intent != null) { - String gfxBackend = intent.getStringExtra("gfx-backend"); - if(gfxBackend != null) { - if(gfxBackend.equals("Vulkan")) { - launchArguments = new String[] {"gfx_backend Vulkan"}; - } else if(gfxBackend.equals("GLES")) { - launchArguments = new String[] {"gfx_backend GLES"}; - } - } - } - - super.onCreate(SavedInstanceState); - } - - @Override - protected String[] getArguments() { - return launchArguments; - } - - @Override - protected boolean onUnhandledMessage(int command, Object param) { - if(command == COMMAND_RESTART_APP) { - restartApp(); - return true; - } - return false; - } - - private void restartApp() { - Intent restartIntent = - Intent.makeRestartActivityTask( - getPackageManager().getLaunchIntentForPackage( - getPackageName() - ).getComponent() - ); - restartIntent.setPackage(getPackageName()); - startActivity(restartIntent); - } -} diff --git a/scripts/android/files/java/org/ddnet/client/ServerService.java b/scripts/android/files/java/org/ddnet/client/ServerService.java new file mode 100644 index 00000000000..0aa41ad50c2 --- /dev/null +++ b/scripts/android/files/java/org/ddnet/client/ServerService.java @@ -0,0 +1,257 @@ +package org.ddnet.client; + +import java.io.File; + +import android.app.*; +import android.content.*; +import android.content.pm.ServiceInfo; +import android.os.*; +import android.util.*; +import android.widget.Toast; + +import androidx.core.app.NotificationCompat; + +public class ServerService extends Service { + + private static final String NOTIFICATION_CHANNEL_ID = "LOCAL_SERVER_CHANNEL_ID"; + private static final int NOTIFICATION_ID = 1; + + public static final int MESSAGE_STOP = 1; + + public static final String INTENT_ACTION_STOP = "stop"; + + static { + System.loadLibrary("DDNet-Server"); + } + + private class IncomingHandler extends Handler { + + IncomingHandler(Context context) { + super(context.getMainLooper()); + } + + @Override + public void handleMessage(Message message) { + switch(message.what) { + case MESSAGE_STOP: + stopServer(); + break; + default: + super.handleMessage(message); + break; + } + } + } + + private Messenger messenger; + private NotificationManager notificationManager; + private NativeServerThread thread; + private boolean stopping = false; + + @Override + public void onCreate() { + super.onCreate(); + + notificationManager = getSystemService(NotificationManager.class); + + createNotificationChannel(); + + Notification notification = createRunningNotification(); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + ); + } else { + startForeground( + NOTIFICATION_ID, + notification + ); + } + + thread = new NativeServerThread(this); + thread.start(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if(intent != null) { + String action = intent.getAction(); + if(INTENT_ACTION_STOP.equals(action)) { + stopServer(); + } + } + return START_NOT_STICKY; + } + + public void onDestroy() { + super.onDestroy(); + stopServer(); + stopForeground(0); + if(thread != null) { + try { + thread.join(); + } catch (InterruptedException e) { + } + thread = null; + } + } + + @Override + public IBinder onBind(Intent intent) { + messenger = new Messenger(new IncomingHandler(this)); + return messenger.getBinder(); + } + + @Override + public boolean onUnbind(Intent intent) { + // Ensure server is stopped when the client is unbound from this service, + // which covers the case where the client activity is killed while in the + // background, which is otherwise not possible to detect. + stopServer(); + return false; + } + + private void createNotificationChannel() { + if(Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationChannel channel = new NotificationChannel( + NOTIFICATION_CHANNEL_ID, + getString(R.string.server_name), + NotificationManager.IMPORTANCE_DEFAULT + ); + channel.setDescription(getString(R.string.server_notification_channel_description)); + notificationManager.createNotificationChannel(channel); + } + + private Notification createRunningNotification() { + // TODO: Update the notification text while server is running: show our LAN IP, show current player count. + // Needs to be throttled or updates will be dropped, maybe use a separate thread that updates the + // notification every 3-5 seconds. + // TODO: Add action with "reply" to execute arbitrary command via user input + + Intent activityIntent = new Intent(this, ClientActivity.class); + + PendingIntent activityActionIntent = PendingIntent.getActivity( + this, + 0, // request code (unused) + activityIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + Intent stopIntent = new Intent(this, ServerService.class); + stopIntent.setAction(INTENT_ACTION_STOP); + + PendingIntent stopActionIntent = PendingIntent.getService( + this, + 0, // request code (unused) + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + NotificationCompat.Action stopAction = + new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_view, + getString(R.string.server_notification_action_stop), + stopActionIntent + ).setAuthenticationRequired(true) + .build(); + + return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setOngoing(true) + .setContentTitle(getString(R.string.server_name)) + .setContentText(getString(R.string.server_notification_description_default)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(activityActionIntent) + .addAction(stopAction) + .build(); + } + + private Notification createStoppingNotification() { + return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setOngoing(true) + .setContentTitle(getString(R.string.server_name)) + .setContentText(getString(R.string.server_notification_description_stopping)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .build(); + } + + private void stopServer() { + if(stopping) { + return; + } + stopping = true; + notificationManager.notify(NOTIFICATION_ID, createStoppingNotification()); + if(thread != null) { + NativeServer.stopServer(); + } + } +} + +/** + * Thread that runs the native server's main function. This thread is necessary so + * we don't block the service's main thread which is responsible for handling the + * service's lifecycle. + */ +class NativeServerThread extends Thread { + + private final Context applicationContext; + + public NativeServerThread(Context context) { + this.applicationContext = context.getApplicationContext(); + } + + @Override + public void run() { + File workingDirectory = applicationContext.getExternalFilesDir(null); + if(workingDirectory == null) { + new Handler(applicationContext.getMainLooper()).post(() -> { + Toast.makeText(applicationContext, R.string.server_error_external_files_inaccessible, Toast.LENGTH_LONG).show(); + terminateProcess(); + }); + return; + } + + int Result = NativeServer.runServer(workingDirectory.getAbsolutePath()); + new Handler(applicationContext.getMainLooper()).post(() -> { + if(Result != 0) { + Toast.makeText(applicationContext, applicationContext.getString(R.string.server_error_exit_code, Result), Toast.LENGTH_LONG).show(); + } + terminateProcess(); + }); + } + + private static void terminateProcess() { + // Forcefully terminate the entire process, to ensure that static variables will + // be initialized correctly when the server is started again after being stopped. + System.exit(0); + } +} + +/** + * Wrapper for functions that are implemented using JNI in engine/server/main.cpp. + */ +class NativeServer { + + private NativeServer() { + throw new AssertionError(); + } + + /** + * Runs the native server main function in the current thread and returns the + * exit code on completion. + * + * @param workingDirectory The working directory for the server, which must be the + * external storage directory of the app and already contains all data files. + */ + public static native int runServer(String workingDirectory); + + /** + * Signals the native server to stop. + */ + public static native void stopServer(); +} diff --git a/scripts/android/files/res/values/strings.xml b/scripts/android/files/res/values/strings.xml index c9b6483ce76..22f402e8a7e 100644 --- a/scripts/android/files/res/values/strings.xml +++ b/scripts/android/files/res/values/strings.xml @@ -3,4 +3,11 @@ DDNet Play (Vulkan) Play (OpenGL ES) + DDNet-Server + Local DDNet-Server is running… + Local DDNet-Server is stopping… + Stop + Notification to control the local DDNet-Server while it\'s running. + Error starting DDNet-Server: could not access external files directory. + DDNet-Server stopped with error code \'%1$d\'. diff --git a/src/android/android_main.cpp b/src/android/android_main.cpp index e60ec49765b..6d4a6281cee 100644 --- a/src/android/android_main.cpp +++ b/src/android/android_main.cpp @@ -1,5 +1,6 @@ #include "android_main.h" +#include #include #include @@ -226,7 +227,7 @@ const char *InitAndroid() return nullptr; } -// See NativeMain.java +// See ClientActivity.java constexpr uint32_t COMMAND_USER = 0x8000; constexpr uint32_t COMMAND_RESTART_APP = COMMAND_USER + 1; @@ -234,3 +235,52 @@ void RestartAndroidApp() { SDL_AndroidSendMessage(COMMAND_RESTART_APP, 0); } + +bool StartAndroidServer() +{ + // We need the notification-permission to show a notification for the foreground service. + if(!SDL_AndroidRequestPermission("android.permission.POST_NOTIFICATIONS")) + { + return false; + } + + JNIEnv *pEnv = static_cast(SDL_AndroidGetJNIEnv()); + jobject Activity = (jobject)SDL_AndroidGetActivity(); + jclass ActivityClass = pEnv->GetObjectClass(Activity); + + jmethodID MethodId = pEnv->GetMethodID(ActivityClass, "startServer", "()V"); + pEnv->CallVoidMethod(Activity, MethodId); + + pEnv->DeleteLocalRef(Activity); + pEnv->DeleteLocalRef(ActivityClass); + + return true; +} + +void StopAndroidServer() +{ + JNIEnv *pEnv = static_cast(SDL_AndroidGetJNIEnv()); + jobject Activity = (jobject)SDL_AndroidGetActivity(); + jclass ActivityClass = pEnv->GetObjectClass(Activity); + + jmethodID MethodId = pEnv->GetMethodID(ActivityClass, "stopServer", "()V"); + pEnv->CallVoidMethod(Activity, MethodId); + + pEnv->DeleteLocalRef(Activity); + pEnv->DeleteLocalRef(ActivityClass); +} + +bool IsAndroidServerRunning() +{ + JNIEnv *pEnv = static_cast(SDL_AndroidGetJNIEnv()); + jobject Activity = (jobject)SDL_AndroidGetActivity(); + jclass ActivityClass = pEnv->GetObjectClass(Activity); + + jmethodID MethodId = pEnv->GetMethodID(ActivityClass, "isServerRunning", "()Z"); + const bool Result = pEnv->CallBooleanMethod(Activity, MethodId); + + pEnv->DeleteLocalRef(Activity); + pEnv->DeleteLocalRef(ActivityClass); + + return Result; +} diff --git a/src/android/android_main.h b/src/android/android_main.h index c7e3a3e88d4..bc7da00df5d 100644 --- a/src/android/android_main.h +++ b/src/android/android_main.h @@ -6,10 +6,23 @@ #error "This header should only be included when compiling for Android" #endif +/** + * @defgroup Android + * + * Android-specific functions to interact with the ClientActivity. + * + * Important note: These functions may only be called from the main native thread + * which is created by the SDLActivity (super class of ClientActivity), otherwise + * JNI calls are not possible because the JNI environment is not attached to that + * thread. See https://developer.android.com/training/articles/perf-jni#threads + */ + /** * Initializes the Android storage. Must be called on Android-systems * before using any of the I/O and storage functions. * + * @ingroup Android + * * This will change the current working directory to the app specific external * storage location and unpack the assets from the APK file to the `data` folder. * The folder `user` is created in the external storage to store the user data. @@ -23,9 +36,43 @@ const char *InitAndroid(); /** * Sends an intent to the Android system to restart the app. * + * @ingroup Android + * * This will restart the main activity in a new task. The current process * must immediately terminate after this function is called. */ void RestartAndroidApp(); +/** + * Starts the local server as an Android service. + * + * @ingroup Android + * + * This will request the notification-permission as it is required for + * foreground services to show a notification. + * + * @return `true` on success, `false` on error. + */ +bool StartAndroidServer(); + +/** + * Stops the local server and its Android service. + * + * @ingroup Android + * + * This signals the server to shutdown properly. The server will shutdown within + * the next few seconds. Use the @link IsAndroidServerRunning @endlink function + * to check if the server has stopped. + */ +void StopAndroidServer(); + +/** + * Returns whether the local server and its Android service are running. + * + * @ingroup Android + * + * @return `true` if the server is running, `false` if the server is stopped. + */ +bool IsAndroidServerRunning(); + #endif // ANDROID_ANDROID_MAIN_H diff --git a/src/engine/server/main.cpp b/src/engine/server/main.cpp index 5904b3245c9..de53e4631d8 100644 --- a/src/engine/server/main.cpp +++ b/src/engine/server/main.cpp @@ -21,6 +21,8 @@ #if defined(CONF_FAMILY_WINDOWS) #include +#elif defined(CONF_PLATFORM_ANDROID) +#include #endif #include @@ -46,6 +48,8 @@ int main(int argc, const char **argv) const int64_t MainStart = time_get(); CCmdlineFix CmdlineFix(&argc, &argv); + +#if !defined(CONF_PLATFORM_ANDROID) bool Silent = false; for(int i = 1; i < argc; i++) @@ -59,6 +63,7 @@ int main(int argc, const char **argv) break; } } +#endif #if defined(CONF_FAMILY_WINDOWS) CWindowsComLifecycle WindowsComLifecycle(false); @@ -206,3 +211,35 @@ int main(int argc, const char **argv) return Ret; } + +#if defined(CONF_PLATFORM_ANDROID) +#if !defined(ANDROID_PACKAGE_NAME) +#error "ANDROID_PACKAGE_NAME must define the package name when compiling for Android (using underscores instead of dots, e.g. org_example_app)" +#endif +// Helpers to force macro expansion else the ANDROID_PACKAGE_NAME macro is not expanded +#define EXPAND_MACRO(x) x +#define JNI_MAKE_NAME(PACKAGE, CLASS, FUNCTION) Java_##PACKAGE##_##CLASS##_##FUNCTION +#define JNI_EXPORTED_FUNCTION(PACKAGE, CLASS, FUNCTION, RETURN_TYPE, ...) \ + extern "C" JNIEXPORT RETURN_TYPE JNICALL EXPAND_MACRO(JNI_MAKE_NAME(PACKAGE, CLASS, FUNCTION))(__VA_ARGS__) + +JNI_EXPORTED_FUNCTION(ANDROID_PACKAGE_NAME, NativeServer, runServer, jint, JNIEnv *pEnv, jobject Object, jstring WorkingDirectory) +{ + // Set working directory to external storage location. This is not possible + // in Java so we pass the intended working directory to the native code. + const char *pWorkingDirectory = pEnv->GetStringUTFChars(WorkingDirectory, nullptr); + if(fs_chdir(pWorkingDirectory) != 0) + { + pEnv->ReleaseStringUTFChars(WorkingDirectory, pWorkingDirectory); + return -1001; + } + pEnv->ReleaseStringUTFChars(WorkingDirectory, pWorkingDirectory); + + const char *apArgs[] = {GAME_NAME}; + return main(std::size(apArgs), apArgs); +} + +JNI_EXPORTED_FUNCTION(ANDROID_PACKAGE_NAME, NativeServer, stopServer, void, JNIEnv *pEnv, jobject Object) +{ + InterruptSignaled = 1; +} +#endif diff --git a/src/game/client/components/menus.cpp b/src/game/client/components/menus.cpp index c498bc8c11f..cd26b166b6a 100644 --- a/src/game/client/components/menus.cpp +++ b/src/game/client/components/menus.cpp @@ -74,8 +74,6 @@ CMenus::CMenus() m_DemoPlayerState = DEMOPLAYER_NONE; m_Dummy = false; - m_ServerProcess.m_Process = INVALID_PROCESS; - for(SUIAnimator &animator : m_aAnimatorsSettingsTab) { animator.m_YOffset = -2.5f; diff --git a/src/game/client/components/menus.h b/src/game/client/components/menus.h index 8e6d255214a..2af526b37c5 100644 --- a/src/game/client/components/menus.h +++ b/src/game/client/components/menus.h @@ -33,7 +33,9 @@ struct CServerProcess { - PROCESS m_Process; +#if !defined(CONF_PLATFORM_ANDROID) + PROCESS m_Process = INVALID_PROCESS; +#endif }; // component to fetch keypresses, override all other input @@ -669,7 +671,9 @@ class CMenus : public CComponent bool IsActive() const { return m_MenuActive; } void SetActive(bool Active); + void RunServer(); void KillServer(); + bool IsServerRunning() const; virtual void OnInit() override; void OnConsoleInit() override; diff --git a/src/game/client/components/menus_start.cpp b/src/game/client/components/menus_start.cpp index 0224b7c2672..caa43f9e78e 100644 --- a/src/game/client/components/menus_start.cpp +++ b/src/game/client/components/menus_start.cpp @@ -17,6 +17,10 @@ #include "menus.h" +#if defined(CONF_PLATFORM_ANDROID) +#include +#endif + using namespace FontIcons; void CMenus::RenderStartMenu(CUIRect MainView) @@ -124,37 +128,26 @@ void CMenus::RenderStartMenu(CUIRect MainView) if(DoButton_Menu(&s_SettingsButton, Localize("Settings"), 0, &Button, g_Config.m_ClShowStartMenuImages ? "settings" : 0, IGraphics::CORNER_ALL, Rounding, 0.5f, ColorRGBA(0.0f, 0.0f, 0.0f, 0.25f)) || CheckHotKey(KEY_S)) NewPage = PAGE_SETTINGS; -#if !defined(CONF_PLATFORM_ANDROID) Menu.HSplitBottom(5.0f, &Menu, 0); // little space Menu.HSplitBottom(40.0f, &Menu, &Button); static CButtonContainer s_LocalServerButton; +#if !defined(CONF_PLATFORM_ANDROID) if(!is_process_alive(m_ServerProcess.m_Process)) KillServer(); +#endif - if(DoButton_Menu(&s_LocalServerButton, m_ServerProcess.m_Process ? Localize("Stop server") : Localize("Run server"), 0, &Button, g_Config.m_ClShowStartMenuImages ? "local_server" : 0, IGraphics::CORNER_ALL, Rounding, 0.5f, m_ServerProcess.m_Process ? ColorRGBA(0.0f, 1.0f, 0.0f, 0.25f) : ColorRGBA(0.0f, 0.0f, 0.0f, 0.25f)) || (CheckHotKey(KEY_R) && Input()->KeyPress(KEY_R))) + if(DoButton_Menu(&s_LocalServerButton, IsServerRunning() ? Localize("Stop server") : Localize("Run server"), 0, &Button, g_Config.m_ClShowStartMenuImages ? "local_server" : 0, IGraphics::CORNER_ALL, Rounding, 0.5f, IsServerRunning() ? ColorRGBA(0.0f, 1.0f, 0.0f, 0.25f) : ColorRGBA(0.0f, 0.0f, 0.0f, 0.25f)) || (CheckHotKey(KEY_R) && Input()->KeyPress(KEY_R))) { - if(m_ServerProcess.m_Process) + if(IsServerRunning()) { KillServer(); } else { - char aBuf[IO_MAX_PATH_LENGTH]; - Storage()->GetBinaryPath(PLAT_SERVER_EXEC, aBuf, sizeof(aBuf)); - // No / in binary path means to search in $PATH, so it is expected that the file can't be opened. Just try executing anyway. - if(str_find(aBuf, "/") == 0 || fs_is_file(aBuf)) - { - m_ServerProcess.m_Process = shell_execute(aBuf, EShellExecuteWindowState::BACKGROUND); - m_ForceRefreshLanPage = true; - } - else - { - Client()->AddWarning(SWarning(Localize("Server executable not found, can't run server"))); - } + RunServer(); } } -#endif Menu.HSplitBottom(5.0f, &Menu, 0); // little space Menu.HSplitBottom(40.0f, &Menu, &Button); @@ -283,16 +276,52 @@ void CMenus::RenderStartMenu(CUIRect MainView) } } +void CMenus::RunServer() +{ +#if defined(CONF_PLATFORM_ANDROID) + if(StartAndroidServer()) + { + m_ForceRefreshLanPage = true; + } + else + { + Client()->AddWarning(SWarning(Localize("Server could not be started. Make sure to grant the notification permission in the app settings so the server can run in the background."))); + } +#else + char aBuf[IO_MAX_PATH_LENGTH]; + Storage()->GetBinaryPath(PLAT_SERVER_EXEC, aBuf, sizeof(aBuf)); + // No / in binary path means to search in $PATH, so it is expected that the file can't be opened. Just try executing anyway. + if(str_find(aBuf, "/") == 0 || fs_is_file(aBuf)) + { + m_ServerProcess.m_Process = shell_execute(aBuf, EShellExecuteWindowState::BACKGROUND); + m_ForceRefreshLanPage = true; + } + else + { + Client()->AddWarning(SWarning(Localize("Server executable not found, can't run server"))); + } +#endif +} + void CMenus::KillServer() { -#if !defined(CONF_PLATFORM_ANDROID) - if(m_ServerProcess.m_Process) +#if defined(CONF_PLATFORM_ANDROID) + StopAndroidServer(); + m_ForceRefreshLanPage = true; +#else + if(m_ServerProcess.m_Process && kill_process(m_ServerProcess.m_Process)) { - if(kill_process(m_ServerProcess.m_Process)) - { - m_ServerProcess.m_Process = INVALID_PROCESS; - m_ForceRefreshLanPage = true; - } + m_ServerProcess.m_Process = INVALID_PROCESS; + m_ForceRefreshLanPage = true; } #endif } + +bool CMenus::IsServerRunning() const +{ +#if defined(CONF_PLATFORM_ANDROID) + return IsAndroidServerRunning(); +#else + return m_ServerProcess.m_Process != INVALID_PROCESS; +#endif +}