From cef2c2617b48c6a65760d286e6187bad609e3d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20M=C3=BCller?= Date: Fri, 6 Dec 2024 18:04:28 +0100 Subject: [PATCH] Support running local server on Android Compile the server as a separate library for Android same as the client. Use a foreground service to run the native `main` function of the server using Java Native Interface. Directly using JNI avoids the use of SDL as a wrapper for the server. The server service must run in a different process because it needs to be terminated to correctly restart the server. Otherwise it crashes because global variables are not initialized again when it is restarted. This also prevents simply running the server in another thread of the client process. A toast error message is shown on non-zero return values of server's `main` function. A foreground service is used so the server can keep running while the client activity is in the background. Add detailed description why the service uses `android:foregroundServiceType="specialUse"` in the manifest as this should convince the reviewer that the use case is valid. None of the other foreground service types cover hosting local game servers or related use cases. The required permissions for using a foreground service with special use case are added as well as the permission to post notifications. Showing a notification is necessary for foreground services to stay alive and it also allows showing the server status and controlling the server. In particular, an action to directly stop the server is shown. Another action to run commands based on user input in the notification is provided, which allows setting the initial rcon password. The server will also be stopped automatically if the client is quit or if the server notification is deleted (which is possible on newer Android versions that do not allow ongoing notifications anymore). Rename `NativeMain.java` to `ClientActivity.java` to differentiate the file better from the server class. --- CMakeLists.txt | 40 ++- cmake/FindAndroid.cmake | 14 +- scripts/android/cmake_android.sh | 6 +- scripts/android/files/AndroidManifest.xml | 22 +- scripts/android/files/build.gradle | 3 + scripts/android/files/build.sh | 3 +- .../java/org/ddnet/client/ClientActivity.java | 133 ++++++++ .../java/org/ddnet/client/NativeMain.java | 65 ---- .../java/org/ddnet/client/ServerService.java | 316 ++++++++++++++++++ scripts/android/files/res/values/strings.xml | 8 + src/android/android_main.cpp | 62 +++- src/android/android_main.h | 45 +++ src/engine/server/main.cpp | 60 ++++ src/engine/server/server.cpp | 12 + src/game/client/components/menus.cpp | 2 - src/game/client/components/menus.h | 6 +- src/game/client/components/menus_start.cpp | 75 +++-- 17 files changed, 761 insertions(+), 111 deletions(-) create mode 100644 scripts/android/files/java/org/ddnet/client/ClientActivity.java delete mode 100644 scripts/android/files/java/org/ddnet/client/NativeMain.java create mode 100644 scripts/android/files/java/org/ddnet/client/ServerService.java 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..e4ac45733a3 100644 --- a/scripts/android/files/AndroidManifest.xml +++ b/scripts/android/files/AndroidManifest.xml @@ -41,21 +41,27 @@ + + + + + + + + + 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..e51d9c1b7f4 --- /dev/null +++ b/scripts/android/files/java/org/ddnet/client/ClientActivity.java @@ -0,0 +1,133 @@ +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) { + if(serverServiceMessenger != null) { + return; + } + 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 executeCommand(String command) { + synchronized(serverServiceMonitor) { + if(serverServiceMessenger == null) { + return; + } + try { + Message message = Message.obtain(null, ServerService.MESSAGE_CODE_EXECUTE_COMMAND, 0, 0); + message.getData().putString(ServerService.MESSAGE_EXTRA_COMMAND, command); + serverServiceMessenger.send(message); + } catch (RemoteException e) { + // Connection 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..ba297bb786f --- /dev/null +++ b/scripts/android/files/java/org/ddnet/client/ServerService.java @@ -0,0 +1,316 @@ +package org.ddnet.client; + +import java.io.File; + +import androidx.core.app.RemoteInput; +import androidx.core.app.NotificationCompat; + +import android.app.*; +import android.content.*; +import android.content.pm.ServiceInfo; +import android.os.*; +import android.util.*; +import android.widget.Toast; + +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_CODE_EXECUTE_COMMAND = 1; + public static final String MESSAGE_EXTRA_COMMAND = "command"; + + public static final String INTENT_ACTION_EXECUTE = "execute"; + public static final String INTENT_EXTRA_COMMAND = "command"; + private static final String KEY_EXECUTE_TEXT_REPLY = "execute-command-reply"; + + 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_CODE_EXECUTE_COMMAND: + String command = message.getData().getString(MESSAGE_EXTRA_COMMAND); + if(command != null) { + executeCommand(command); + } + 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_EXECUTE.equals(action)) { + Bundle remoteInput = RemoteInput.getResultsFromIntent(intent); + if(remoteInput != null) { + CharSequence remoteCommand = remoteInput.getCharSequence(KEY_EXECUTE_TEXT_REPLY); + if(remoteCommand != null) { + executeCommand(remoteCommand.toString()); + } + if(!stopping) { + // Need to send the notification again to acknowledge that we got the remote input, + // otherwise the remote input will not be completed. + notificationManager.notify(NOTIFICATION_ID, createRunningNotification()); + } + } else { + String command = intent.getStringExtra(INTENT_EXTRA_COMMAND); + if(command != null) { + executeCommand(command); + } + } + } + } + return START_NOT_STICKY; + } + + public void onDestroy() { + super.onDestroy(); + executeCommand("shutdown"); + stopForeground(0); + if(thread != null) { + try { + thread.join(2500); + if(thread.isAlive()) { + // Native server is not reacting to the shutdown command, force stop. + System.exit(0); + } + } 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. + executeCommand("shutdown"); + 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() { + 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_EXECUTE); + stopIntent.putExtra(INTENT_EXTRA_COMMAND, "shutdown"); + + 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) // not allowed from lock screen + .build(); + + Intent executeCommandIntent = new Intent(this, ServerService.class); + executeCommandIntent.setAction(INTENT_ACTION_EXECUTE); + + PendingIntent executeCommandActionIntent = PendingIntent.getService( + this, + 0, // request code (unused) + executeCommandIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE + ); + + RemoteInput remoteInput = + new RemoteInput.Builder(KEY_EXECUTE_TEXT_REPLY) + .setLabel(getString(R.string.server_notification_action_run_command)) + .build(); + + NotificationCompat.Action executeAction = + new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_view, + getString(R.string.server_notification_action_run_command), + executeCommandActionIntent + ).setAuthenticationRequired(true) // not allowed from lock screen + .addRemoteInput(remoteInput) + .build(); + + // TODO: Update the notification text (setContentText) while server is running: + // show our LAN IP, show current player count, show last executed command + return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setOngoing(true) + .setAutoCancel(false) + .setContentTitle(getString(R.string.server_notification_description_default)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAllowSystemGeneratedContextualActions(false) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setContentIntent(activityActionIntent) // clicking on the notification opens the activity + .setDeleteIntent(stopActionIntent) // deleting the notification will also stop the server + .addAction(stopAction) + .addAction(executeAction) + .build(); + } + + private Notification createStoppingNotification() { + return new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setOngoing(true) + .setContentTitle(getString(R.string.server_notification_description_stopping)) + .setSmallIcon(R.mipmap.ic_launcher) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setAllowSystemGeneratedContextualActions(false) + .build(); + } + + private void executeCommand(String command) { + if(thread == null) { + return; + } + // Detect simple case where the server is being stopped to update the notification + if("shutdown".equalsIgnoreCase(command)) { + if(stopping) { + return; + } + stopping = true; + notificationManager.notify(NOTIFICATION_ID, createStoppingNotification()); + } + NativeServer.executeCommand(command); + } +} + +/** + * 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); + + /** + * Adds a command to the execution queue of the native server. + * + * @param command The command to add to the queue. + */ + public static native void executeCommand(String command); +} diff --git a/scripts/android/files/res/values/strings.xml b/scripts/android/files/res/values/strings.xml index c9b6483ce76..f8021c6d644 100644 --- a/scripts/android/files/res/values/strings.xml +++ b/scripts/android/files/res/values/strings.xml @@ -3,4 +3,12 @@ DDNet Play (Vulkan) Play (OpenGL ES) + DDNet-Server + DDNet-Server is running… + DDNet-Server is stopping… + Stop + Run command + 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..37790e689ce 100644 --- a/src/android/android_main.cpp +++ b/src/android/android_main.cpp @@ -1,7 +1,5 @@ #include "android_main.h" -#include - #include #include #include @@ -11,6 +9,10 @@ #include #include +#include + +#include + static bool UnpackAsset(const char *pFilename) { char aAssetFilename[IO_MAX_PATH_LENGTH]; @@ -226,7 +228,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 +236,57 @@ void RestartAndroidApp() { SDL_AndroidSendMessage(COMMAND_RESTART_APP, 0); } + +bool StartAndroidServer() +{ + // We need the notification-permission to show a notification for the foreground service. + // We use SDL for this instead of doing it on the Java side because this function blocks + // until the user made a choice, which is easier to handle. + 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 ExecuteAndroidServerCommand(const char *pCommand) +{ + JNIEnv *pEnv = static_cast(SDL_AndroidGetJNIEnv()); + jobject Activity = (jobject)SDL_AndroidGetActivity(); + jclass ActivityClass = pEnv->GetObjectClass(Activity); + + jstring Command = pEnv->NewStringUTF(pCommand); + + jmethodID MethodId = pEnv->GetMethodID(ActivityClass, "executeCommand", "(Ljava/lang/String;)V"); + pEnv->CallVoidMethod(Activity, MethodId, Command); + + pEnv->DeleteLocalRef(Command); + 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..02fcf8f18c0 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,41 @@ 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(); + +/** + * Adds a command to the execution queue of the local server. + * + * @ingroup Android + * + * @param pCommand The command to enqueue. + */ +void ExecuteAndroidServerCommand(const char *pCommand); + +/** + * 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..1e592923458 100644 --- a/src/engine/server/main.cpp +++ b/src/engine/server/main.cpp @@ -17,10 +17,13 @@ #include +#include #include #if defined(CONF_FAMILY_WINDOWS) #include +#elif defined(CONF_PLATFORM_ANDROID) +#include #endif #include @@ -46,6 +49,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 +64,7 @@ int main(int argc, const char **argv) break; } } +#endif #if defined(CONF_FAMILY_WINDOWS) CWindowsComLifecycle WindowsComLifecycle(false); @@ -206,3 +212,57 @@ 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__) + +std::mutex AndroidNativeMutex; +std::vector vAndroidCommandQueue; + +std::vector FetchAndroidServerCommandQueue() +{ + std::vector vResult; + { + const std::unique_lock Lock(AndroidNativeMutex); + vResult.swap(vAndroidCommandQueue); + } + return vResult; +} + +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); + const bool WorkingDirectoryError = fs_chdir(pWorkingDirectory) != 0; + pEnv->ReleaseStringUTFChars(WorkingDirectory, pWorkingDirectory); + if(WorkingDirectoryError) + { + return -1001; + } + + const char *apArgs[] = {GAME_NAME}; + return main(std::size(apArgs), apArgs); +} + +JNI_EXPORTED_FUNCTION(ANDROID_PACKAGE_NAME, NativeServer, executeCommand, void, JNIEnv *pEnv, jobject Object, jstring Command) +{ + const char *pCommand = pEnv->GetStringUTFChars(Command, nullptr); + { + const std::unique_lock Lock(AndroidNativeMutex); + vAndroidCommandQueue.emplace_back(pCommand); + } + pEnv->ReleaseStringUTFChars(Command, pCommand); +} + +#undef EXPAND_MACRO +#undef JNI_MAKE_NAME +#undef JNI_EXPORTED_FUNCTION +#endif diff --git a/src/engine/server/server.cpp b/src/engine/server/server.cpp index b2c0e93b67e..242639f85b3 100644 --- a/src/engine/server/server.cpp +++ b/src/engine/server/server.cpp @@ -48,6 +48,10 @@ extern bool IsInterrupted(); +#if defined(CONF_PLATFORM_ANDROID) +extern std::vector FetchAndroidServerCommandQueue(); +#endif + void CServerBan::InitServerBan(IConsole *pConsole, IStorage *pStorage, CServer *pServer) { CNetBan::Init(pConsole, pStorage); @@ -2937,6 +2941,14 @@ int CServer::Run() m_Fifo.Update(); +#if defined(CONF_PLATFORM_ANDROID) + std::vector vAndroidCommandQueue = FetchAndroidServerCommandQueue(); + for(const std::string &Command : vAndroidCommandQueue) + { + Console()->ExecuteLineFlag(Command.c_str(), CFGFLAG_SERVER, -1); + } +#endif + // master server stuff m_pRegister->Update(); 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..aa3b11d4fc6 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) + ExecuteAndroidServerCommand("shutdown"); + 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 +}