SDL: Android: Implement open and save file dialog.

From 86fada6faab919837d084646a8d7dcd93fd60ffc Mon Sep 17 00:00:00 2001
From: Miku AuahDark <[EMAIL REDACTED]>
Date: Mon, 6 May 2024 12:14:20 +0800
Subject: [PATCH] Android: Implement open and save file dialog.

---
 Android.mk                                    |   2 +
 CMakeLists.txt                                |   5 +-
 android-project/app/proguard-rules.pro        |   2 +
 .../main/java/org/libsdl/app/SDLActivity.java | 111 +++++++++++++
 src/core/android/SDL_android.c                | 146 +++++++++++++++++-
 src/core/android/SDL_android.h                |   4 +
 src/dialog/android/SDL_androiddialog.c        |  45 ++++++
 7 files changed, 312 insertions(+), 3 deletions(-)
 create mode 100644 src/dialog/android/SDL_androiddialog.c

diff --git a/Android.mk b/Android.mk
index a60a0d3636fa3..3798a2516c1f6 100644
--- a/Android.mk
+++ b/Android.mk
@@ -30,6 +30,8 @@ LOCAL_SRC_FILES := \
 	$(wildcard $(LOCAL_PATH)/src/core/*.c) \
 	$(wildcard $(LOCAL_PATH)/src/core/android/*.c) \
 	$(wildcard $(LOCAL_PATH)/src/cpuinfo/*.c) \
+    $(LOCAL_PATH)/src/dialog/SDL_dialog_utils.c \
+    $(LOCAL_PATH)/src/dialog/android/SDL_androiddialog.c \
 	$(wildcard $(LOCAL_PATH)/src/dynapi/*.c) \
 	$(wildcard $(LOCAL_PATH)/src/events/*.c) \
 	$(wildcard $(LOCAL_PATH)/src/file/*.c) \
diff --git a/CMakeLists.txt b/CMakeLists.txt
index b8cd37904fea1..06712aeed687d 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -2874,7 +2874,10 @@ endif()
 
 if (SDL_DIALOG)
   sdl_sources(${SDL3_SOURCE_DIR}/src/dialog/SDL_dialog_utils.c)
-  if(UNIX AND NOT APPLE AND NOT RISCOS AND NOT HAIKU)
+  if(ANDROID)
+    sdl_sources(${SDL3_SOURCE_DIR}/src/dialog/android/SDL_androiddialog.c)
+    set(HAVE_SDL_DIALOG TRUE)
+  elseif(UNIX AND NOT APPLE AND NOT RISCOS AND NOT HAIKU)
     sdl_sources(${SDL3_SOURCE_DIR}/src/dialog/unix/SDL_unixdialog.c)
     sdl_sources(${SDL3_SOURCE_DIR}/src/dialog/unix/SDL_portaldialog.c)
     sdl_sources(${SDL3_SOURCE_DIR}/src/dialog/unix/SDL_zenitydialog.c)
diff --git a/android-project/app/proguard-rules.pro b/android-project/app/proguard-rules.pro
index b608d09b1afae..e717ea0b70935 100644
--- a/android-project/app/proguard-rules.pro
+++ b/android-project/app/proguard-rules.pro
@@ -49,6 +49,8 @@
     int showToast(java.lang.String, int, int, int, int);
     native java.lang.String nativeGetHint(java.lang.String);
     int openFileDescriptor(java.lang.String, java.lang.String);
+    boolean showFileDialog(java.lang.String[], boolean, boolean, int);
+    native void onNativeFileDialog(int, java.lang.String[], int);
 }
 
 -keep,includedescriptorclasses,allowoptimization class org.libsdl.app.HIDDeviceManager {
diff --git a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
index a24ded14ccdae..f99168d59ce39 100644
--- a/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
+++ b/android-project/app/src/main/java/org/libsdl/app/SDLActivity.java
@@ -4,6 +4,7 @@
 import android.app.AlertDialog;
 import android.app.Dialog;
 import android.app.UiModeManager;
+import android.content.ActivityNotFoundException;
 import android.content.ClipboardManager;
 import android.content.ClipData;
 import android.content.Context;
@@ -39,6 +40,7 @@
 import android.view.WindowManager;
 import android.view.inputmethod.InputConnection;
 import android.view.inputmethod.InputMethodManager;
+import android.webkit.MimeTypeMap;
 import android.widget.Button;
 import android.widget.LinearLayout;
 import android.widget.RelativeLayout;
@@ -46,6 +48,7 @@
 import android.widget.Toast;
 
 import java.io.FileNotFoundException;
+import java.util.ArrayList;
 import java.util.Hashtable;
 import java.util.Locale;
 
@@ -227,6 +230,7 @@ public enum NativeState {
     protected static Thread mSDLThread;
     protected static boolean mSDLMainFinished = false;
     protected static boolean mActivityCreated = false;
+    private static SDLFileDialogState mFileDialogState = null;
 
     protected static SDLGenericMotionListener_API12 getMotionListener() {
         if (mMotionListener == null) {
@@ -719,6 +723,43 @@ public void onBackPressed() {
         }
     }
 
+    @Override
+    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
+        super.onActivityResult(requestCode, resultCode, data);
+
+        if (mFileDialogState != null && mFileDialogState.requestCode == requestCode) {
+            /* This is our file dialog */
+            String[] filelist = null;
+
+            if (data != null) {
+                Uri singleFileUri = data.getData();
+
+                if (singleFileUri == null) {
+                    /* Use Intent.getClipData to get multiple choices */
+                    ClipData clipData = data.getClipData();
+                    assert clipData != null;
+
+                    filelist = new String[clipData.getItemCount()];
+
+                    for (int i = 0; i < filelist.length; i++) {
+                        String uri = clipData.getItemAt(i).getUri().toString();
+                        filelist[i] = uri;
+                    }
+                } else {
+                    /* Only one file is selected. */
+                    filelist = new String[]{singleFileUri.toString()};
+                }
+            } else {
+                /* User cancelled the request. */
+                filelist = new String[0];
+            }
+
+            // TODO: Detect the file MIME type and pass the filter value accordingly.
+            SDLActivity.onNativeFileDialog(requestCode, filelist, -1);
+            mFileDialogState = null;
+        }
+    }
+
     // Called by JNI from SDL.
     public static void manualBackButton() {
         mSingleton.pressBackButton();
@@ -1021,6 +1062,7 @@ public static native void onNativeTouch(int touchDevId, int pointerFingerId,
     public static native void onNativeDarkModeChanged(boolean enabled);
     public static native boolean nativeAllowRecreateActivity();
     public static native int nativeCheckSDLThreadCounter();
+    public static native void onNativeFileDialog(int requestCode, String[] filelist, int filter);
 
     /**
      * This method is called by SDL using JNI.
@@ -1957,6 +1999,75 @@ public static int openFileDescriptor(String uri, String mode) throws Exception {
             return -1;
         }
     }
+
+    /**
+     * This method is called by SDL using JNI.
+     */
+    public static boolean showFileDialog(String[] filters, boolean allowMultiple, boolean forWrite, int requestCode) {
+        if (mSingleton == null) {
+            return false;
+        }
+
+        if (forWrite) {
+            allowMultiple = false;
+        }
+
+        /* Convert string list of extensions to their respective MIME types */
+        ArrayList<String> mimes = new ArrayList<>();
+        MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton();
+        if (filters != null) {
+            for (String pattern : filters) {
+                String[] extensions = pattern.split(";");
+
+                if (extensions.length == 1 && extensions[0].equals("*")) {
+                    /* Handle "*" special case */
+                    mimes.add("*/*");
+                } else {
+                    for (String ext : extensions) {
+                        String mime = mimeTypeMap.getMimeTypeFromExtension(ext);
+                        if (mime != null) {
+                            mimes.add(mime);
+                        }
+                    }
+                }
+            }
+        }
+
+        /* Display the file dialog */
+        Intent intent = new Intent(forWrite ? Intent.ACTION_CREATE_DOCUMENT : Intent.ACTION_OPEN_DOCUMENT);
+        intent.addCategory(Intent.CATEGORY_OPENABLE);
+        intent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultiple);
+        switch (mimes.size()) {
+            case 0:
+                intent.setType("*/*");
+                break;
+            case 1:
+                intent.setType(mimes.get(0));
+                break;
+            default:
+                intent.setType("*/*");
+                intent.putExtra(Intent.EXTRA_MIME_TYPES, mimes.toArray(new String[]{}));
+        }
+
+        try {
+            mSingleton.startActivityForResult(intent, requestCode);
+        } catch (ActivityNotFoundException e) {
+            Log.e(TAG, "Unable to open file dialog.", e);
+            return false;
+        }
+
+        /* Save current dialog state */
+        mFileDialogState = new SDLFileDialogState();
+        mFileDialogState.requestCode = requestCode;
+        mFileDialogState.multipleChoice = allowMultiple;
+        return true;
+    }
+
+    /* Internal class used to track active open file dialog */
+    static class SDLFileDialogState {
+        int requestCode;
+        boolean multipleChoice;
+    }
 }
 
 /**
diff --git a/src/core/android/SDL_android.c b/src/core/android/SDL_android.c
index 4929039607f61..bb6e2a00a683c 100644
--- a/src/core/android/SDL_android.c
+++ b/src/core/android/SDL_android.c
@@ -177,6 +177,10 @@ JNIEXPORT jboolean JNICALL SDL_JAVA_INTERFACE(nativeAllowRecreateActivity)(
 JNIEXPORT int JNICALL SDL_JAVA_INTERFACE(nativeCheckSDLThreadCounter)(
     JNIEnv *env, jclass jcls);
 
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeFileDialog)(
+    JNIEnv *env, jclass jcls,
+    jint requestCode, jobjectArray fileList, jint filter);
+
 static JNINativeMethod SDLActivity_tab[] = {
     { "nativeGetVersion", "()Ljava/lang/String;", SDL_JAVA_INTERFACE(nativeGetVersion) },
     { "nativeSetupJNI", "()I", SDL_JAVA_INTERFACE(nativeSetupJNI) },
@@ -211,7 +215,8 @@ static JNINativeMethod SDLActivity_tab[] = {
     { "nativeAddTouch", "(ILjava/lang/String;)V", SDL_JAVA_INTERFACE(nativeAddTouch) },
     { "nativePermissionResult", "(IZ)V", SDL_JAVA_INTERFACE(nativePermissionResult) },
     { "nativeAllowRecreateActivity", "()Z", SDL_JAVA_INTERFACE(nativeAllowRecreateActivity) },
-    { "nativeCheckSDLThreadCounter", "()I", SDL_JAVA_INTERFACE(nativeCheckSDLThreadCounter) }
+    { "nativeCheckSDLThreadCounter", "()I", SDL_JAVA_INTERFACE(nativeCheckSDLThreadCounter) },
+    { "onNativeFileDialog", "(I[Ljava/lang/String;I)V", SDL_JAVA_INTERFACE(onNativeFileDialog) }
 };
 
 /* Java class SDLInputConnection */
@@ -346,6 +351,7 @@ static jmethodID midShouldMinimizeOnFocusLoss;
 static jmethodID midShowTextInput;
 static jmethodID midSupportsRelativeMouse;
 static jmethodID midOpenFileDescriptor;
+static jmethodID midShowFileDialog;
 
 /* audio manager */
 static jclass mAudioManagerClass;
@@ -640,6 +646,7 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl
     midShowTextInput = (*env)->GetStaticMethodID(env, mActivityClass, "showTextInput", "(IIII)Z");
     midSupportsRelativeMouse = (*env)->GetStaticMethodID(env, mActivityClass, "supportsRelativeMouse", "()Z");
     midOpenFileDescriptor = (*env)->GetStaticMethodID(env, mActivityClass, "openFileDescriptor", "(Ljava/lang/String;Ljava/lang/String;)I");
+    midShowFileDialog = (*env)->GetStaticMethodID(env, mActivityClass, "showFileDialog", "([Ljava/lang/String;ZZI)Z");
 
     if (!midClipboardGetText ||
         !midClipboardHasText ||
@@ -670,7 +677,8 @@ JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(nativeSetupJNI)(JNIEnv *env, jclass cl
         !midShouldMinimizeOnFocusLoss ||
         !midShowTextInput ||
         !midSupportsRelativeMouse ||
-        !midOpenFileDescriptor) {
+        !midOpenFileDescriptor ||
+        !midShowFileDialog) {
         __android_log_print(ANDROID_LOG_WARN, "SDL", "Missing some Java callbacks, do you have the latest version of SDLActivity.java?");
     }
 
@@ -2823,4 +2831,138 @@ int Android_JNI_OpenFileDescriptor(const char *uri, const char *mode)
     return fd;
 }
 
+static struct AndroidFileDialog
+{
+    int request_code;
+    SDL_DialogFileCallback callback;
+    void *userdata;
+} mAndroidFileDialogData;
+
+JNIEXPORT void JNICALL SDL_JAVA_INTERFACE(onNativeFileDialog)(
+    JNIEnv *env, jclass jcls,
+    jint requestCode, jobjectArray fileList, jint filter)
+{
+    if (mAndroidFileDialogData.callback != NULL && mAndroidFileDialogData.request_code == requestCode) {
+        if (fileList == NULL) {
+            SDL_SetError("Unspecified error in JNI");
+            mAndroidFileDialogData.callback(mAndroidFileDialogData.userdata, NULL, -1);
+            mAndroidFileDialogData.callback = NULL;
+            return;
+        }
+
+        /* Convert fileList to string */
+        size_t count = (*env)->GetArrayLength(env, fileList);
+        char **charFileList = SDL_calloc(sizeof(char*), count + 1);
+
+        if (charFileList == NULL) {
+            SDL_OutOfMemory();
+            mAndroidFileDialogData.callback(mAndroidFileDialogData.userdata, NULL, -1);
+            mAndroidFileDialogData.callback = NULL;
+            return;
+        }
+
+        /* Convert to UTF-8 */
+        /* TODO: Fix modified UTF-8 to classic UTF-8 */
+        for (int i = 0; i < count; i++) {
+            jstring string = (*env)->GetObjectArrayElement(env, fileList, i);
+            if (!string) {
+                continue;
+            }
+
+            const char *utf8string = (*env)->GetStringUTFChars(env, string, NULL);
+            if (!utf8string) {
+                (*env)->DeleteLocalRef(env, string);
+                continue;
+            }
+
+            char *newFile = SDL_strdup(utf8string);
+            if (!newFile) {
+                (*env)->ReleaseStringUTFChars(env, string, utf8string);
+                (*env)->DeleteLocalRef(env, string);
+                SDL_OutOfMemory();
+                mAndroidFileDialogData.callback(mAndroidFileDialogData.userdata, NULL, -1);
+                mAndroidFileDialogData.callback = NULL;
+
+                /* Cleanup memory */
+                for (int j = 0; j < i; j++) {
+                    SDL_free(charFileList[j]);
+                }
+                SDL_free(charFileList);
+                return;
+            }
+
+            charFileList[i] = newFile;
+            (*env)->ReleaseStringUTFChars(env, string, utf8string);
+            (*env)->DeleteLocalRef(env, string);
+        }
+
+        /* Call user-provided callback */
+        SDL_ClearError();
+        mAndroidFileDialogData.callback(mAndroidFileDialogData.userdata, (const char *const *) charFileList, filter);
+        mAndroidFileDialogData.callback = NULL;
+
+        /* Cleanup memory */
+        for (int i = 0; i < count; i++) {
+            SDL_free(charFileList[i]);
+        }
+        SDL_free(charFileList);
+    }
+}
+
+SDL_bool Android_JNI_OpenFileDialog(
+    SDL_DialogFileCallback callback, void* userdata,
+    const SDL_DialogFileFilter *filters, SDL_bool forwrite, SDL_bool multiple)
+{
+    if (mAndroidFileDialogData.callback != NULL) {
+        SDL_SetError("Only one file dialog can be run at a time.");
+        return SDL_FALSE;
+    }
+
+    if (forwrite) {
+        multiple = SDL_FALSE;
+    }
+
+    JNIEnv *env = Android_JNI_GetEnv();
+
+    /* Setup filters */
+    jobjectArray filtersArray = NULL;
+    if (filters) {
+        /* Count how many filters */
+        int count = 0;
+        for (const SDL_DialogFileFilter *f = filters; f->name != NULL && f->pattern != NULL; f++) {
+            count++;
+        }
+
+        jclass stringClass = (*env)->FindClass(env, "java/lang/String");
+        filtersArray = (*env)->NewObjectArray(env, count, stringClass, NULL);
+
+        /* Convert to string */
+        for (int i = 0; i < count; i++) {
+            jstring str = (*env)->NewStringUTF(env, filters[i].pattern);
+            (*env)->SetObjectArrayElement(env, filtersArray, i, str);
+            (*env)->DeleteLocalRef(env, str);
+        }
+    }
+
+    /* Setup data */
+    static SDL_AtomicInt next_request_code;
+    mAndroidFileDialogData.request_code = SDL_AtomicAdd(&next_request_code, 1);
+    mAndroidFileDialogData.userdata = userdata;
+    mAndroidFileDialogData.callback = callback;
+
+    /* Invoke JNI */
+    jboolean success = (*env)->CallStaticBooleanMethod(env, mActivityClass,
+        midShowFileDialog, filtersArray, (jboolean) multiple, (jboolean) forwrite, mAndroidFileDialogData.request_code);
+    (*env)->DeleteLocalRef(env, filtersArray);
+    if (!success) {
+        mAndroidFileDialogData.callback = NULL;
+        SDL_AtomicAdd(&next_request_code, -1);
+        SDL_SetError("Unspecified error in JNI");
+
+        return SDL_FALSE;
+    }
+
+    return SDL_TRUE;
+}
+
 #endif /* SDL_PLATFORM_ANDROID */
diff --git a/src/core/android/SDL_android.h b/src/core/android/SDL_android.h
index 3ad8901430f34..db56846b73855 100644
--- a/src/core/android/SDL_android.h
+++ b/src/core/android/SDL_android.h
@@ -142,6 +142,10 @@ void Android_ActivityMutex_Lock(void);
 void Android_ActivityMutex_Unlock(void);
 void Android_ActivityMutex_Lock_Running(void);
 
+/* File Dialogs */
+SDL_bool Android_JNI_OpenFileDialog(SDL_DialogFileCallback callback, void* userdata,
+    const SDL_DialogFileFilter *filters, SDL_bool forwrite, SDL_bool multiple);
+
 /* Ends C function definitions when using C++ */
 #ifdef __cplusplus
 /* *INDENT-OFF* */
diff --git a/src/dialog/android/SDL_androiddialog.c b/src/dialog/android/SDL_androiddialog.c
new file mode 100644
index 0000000000000..c6418f162bee6
--- /dev/null
+++ b/src/dialog/android/SDL_androiddialog.c
@@ -0,0 +1,45 @@
+/*
+  Simple DirectMedia Layer
+  Copyright (C) 1997-2024 Sam Lantinga <slouken@libsdl.org>
+
+  This software is provided 'as-is', without any express or implied
+  warranty.  In no event will the authors be held liable for any damages
+  arising from the use of this software.
+
+  Permission is granted to anyone to use this software for any purpose,
+  including commercial applications, and to alter it and redistribute it
+  freely, subject to the following restrictions:
+
+  1. The origin of this software must not be misrepresented; you must not
+     claim that you wrote the original software. If you use this software
+     in a product, an acknowledgment in the product documentation would be
+     appreciated but is not required.
+  2. Altered source versions must be plainly marked as such, and must not be
+     misrepresented as being the original software.
+  3. This notice may not be removed or altered from any source distribution.
+*/
+
+#include "SDL_internal.h"
+#include "../../core/android/SDL_android.h"
+
+void SDLCALL SDL_ShowOpenFileDialog(SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, const SDL_DialogFileFilter *filters, const char *default_location, SDL_bool allow_many)
+{
+    if (!Android_JNI_OpenFileDialog(callback, userdata, filters, SDL_FALSE, allow_many)) {
+        /* SDL_SetError is already called when it fails */
+        callback(userdata, NULL, -1);
+    }
+}
+
+void SDLCALL SDL_ShowSaveFileDialog(SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, const SDL_DialogFileFilter *filters, const char *default_location)
+{
+    if (!Android_JNI_OpenFileDialog(callback, userdata, filters, SDL_TRUE, SDL_FALSE)) {
+        /* SDL_SetError is already called when it fails */
+        callback(userdata, NULL, -1);
+    }
+}
+
+void SDLCALL SDL_ShowOpenFolderDialog(SDL_DialogFileCallback callback, void *userdata, SDL_Window *window, const char *default_location, SDL_bool allow_many)
+{
+    SDL_Unsupported();
+    callback(userdata, NULL, -1);
+}