AaronRobinsonMSFT / DNNE

Prototype native exports for a .NET Assembly.
MIT License
394 stars 41 forks source link

Possibility of integrating Android/iOS (Mono) support #179

Closed nickwinder closed 11 months ago

nickwinder commented 11 months ago

I've been recently exploring the possibility of supporting Android/iOS platforms calling into .NET code, and up to now I have found very little options.

After finding DNNE, I was able to create a working solution by building the dotnet runtime + libhostfxr + libhostpolicy to create the dependencies needed to build DNNE from scratch for the two targets (actually more because there's multiple archs).

This works, but seems like a convoluted solution which all stems from the fact that iOS and Android .NET solutions utilize Mono, rather than hostfxr which DNNE resolution is based upon.

I'm reaching out to see if I'm missing something fundamental that could simplify the convoluted compilation steps, or possibly modify DNNE to work out the box with Mono instead. Thanks

chschrae commented 11 months ago

Hi there,

While DNNE is not setup for this, there is a way to do .NET on Android using Mono.

What needs to happen is a few things.

  1. You need to setup your Android project to pull all the Mono dll assemblies for the target architecture from the .NET SDK installation. It should be something like ${dotnetInstallDir}\dotnet\packs\Microsoft.NETCore.App.Runtime.Mono.android-arm64
  2. The native binaries from the .NET SDK should be collected in the jniLibs of the Android project that is inferred by the directory path. For example: myAndroidProject\src\main\jniLibs\arm64-v8a
  3. At runtime, the Mono dll's need to be available on disk to load so you will need to unpack them with something like this kotlin code:

    private fun unpackAssetsDlls()
        {
            val assetManager = context.assets
            val files = assetManager.list(CommandSystemBuilder.Companion.COMMAND_SYSTEM_ASSETS_DIR) ?: return
            val path = Paths.get(_commandSystemDllDir)
            Files.createDirectories(path)
            for (fileName in files) {
                if (fileName.endsWith(".dll")) {
                    copyAssetFile(fileName, fileName)
                }
            }

            // Get the private corelib for the right architecture.
            // Check for 64 bit first and keep track if we found one. If
            // we don't find one, it is unsupported.
            val supportedABIs = Build.SUPPORTED_ABIS;
            var selectedABI:String? = null;
            for(abi in supportedABIs) {
                if(abi == "arm64-v8a" || abi == "x86_64" || abi == "armeabi-v7a" || abi == "x86") {
                    selectedABI = abi
                    break;
                }
            }

            if(selectedABI == null){
                throw IllegalArgumentException("Unsupported architecture")
            }
            copyAssetFile("${selectedABI}/System.Private.CoreLib.dll", "System.Private.CoreLib.dll")
        }
  1. You will need to tell Mono where to look for these assemblies. You can do that by registering an assembly preload hook like Xamarin does here: https://github.com/xamarin/xamarin-android/blob/e82abf373420f56d6b96b98bca67a80ce3217fec/src/monodroid/jni/monodroid-glue.cc#L807 https://github.com/xamarin/xamarin-android/blob/e82abf373420f56d6b96b98bca67a80ce3217fec/src/monodroid/jni/monodroid-glue.cc#L1805

This is what I have in my codebase:

      #include <mono/jit/jit.h>
      #include <mono/metadata/appdomain.h>
      #include <mono/metadata/assembly.h>
      #include <mono/metadata/class.h>
      #include <mono/metadata/object.h>
      #define LOG_INFO(fmt, ...) __android_log_print(ANDROID_LOG_DEBUG, "DOTNET", fmt, ##__VA_ARGS__)
      #define LOG_ERROR(fmt, ...) __android_log_print(ANDROID_LOG_ERROR, "DOTNET", fmt, ##__VA_ARGS__)

      static MonoAssembly* mono_droid_load_assembly (const char *name, const char *culture)
      {
          char filename [1024];
          char path [1024];
          int res;

          const char * bundle_path = BundlePath::getInstance()->getPath();
          LOG_INFO ("assembly_preload_hook: %s %s %s\n", name, culture, bundle_path);

          int len = strlen (name);
          int has_extension = len > 3 && name [len - 4] == '.' && (!strcmp ("exe", name + (len - 3)) || !strcmp ("dll", name + (len - 3)));

          // add extensions if required.
          strlcpy (filename, name, sizeof (filename));
          if (!has_extension) {
              strlcat (filename, ".dll", sizeof (filename));
          }

          if (culture && strcmp (culture, ""))
              res = snprintf (path, sizeof (path) - 1, "%s/%s/%s", bundle_path, culture, filename);
          else
              res = snprintf (path, sizeof (path) - 1, "%s/%s", bundle_path, filename);
          assert (res > 0);

          struct stat buffer;
          if (stat (path, &buffer) == 0) {
              MonoAssembly *assembly = mono_assembly_open (path, NULL);
              assert (assembly);
              return assembly;
          }
          return NULL;
      }

          static MonoAssembly* mono_droid_assembly_preload_hook (MonoAssemblyName *aname, char **assemblies_path, void* user_data)
          {
              const char *name = mono_assembly_name_get_name (aname);
              const char *culture = mono_assembly_name_get_culture (aname);
              return mono_droid_load_assembly (name, culture);
          }

      // call this in JNI onLoad
          mono_install_assembly_preload_hook (mono_droid_assembly_preload_hook, NULL);
          mono_jit_init("MyCompany::MyDomainName");
  1. you will need to call System.loadLibrary("myapp_jni"); to load your JNI before you use it.
  2. In JNI you will need to call your .NET functions that are marked with "UnmanagedCallersOnly" by invoking the Mono apis. Here is an example:

    uint32_t MonoCommandSystem::InvokeMonoDOTNETMethod(const char* methodName, uint paramCount, void** params)
    {
    // get NativeDOTNETClass and method
    _MonoClass * staticClass = mono_class_from_name(m_image, "myDotnetNativeAssembly", "NativeDOTNETClass");
    _MonoMethod * staticMethod = mono_class_get_method_from_name( staticClass, methodName, paramCount);
    
    // Call static method
    auto result = mono_runtime_invoke(staticMethod, NULL, params, NULL);
    // unbox result and check error
    void* resultPtr = mono_object_unbox(result);
    
    uint32_t * resultInt = reinterpret_cast<uint32_t*>(resultPtr);
    
    return *resultInt;
    }
  3. Then you just pass that back up your JNI layer to your Android application.

Depending on your application, you might need to have a map of mono objects to JNI/JVM objects to make sure the JVM garbage collector and the Mono garbage collector don't destroy things before you want them to.

In addition if you have any callbacks, you will need to be very careful about managing the mono thread you are attached to so you don't try to access something that you don't have permission to.

This is definitely an advanced way of doing things and has many pitfalls if you aren't familiar with JNI, JVM, garbage collectors, and Mono. I would only go down this path if you have no other choice. Though things are being made easier in .NET 8, they are far from doing this all for you. I hope this helps :)

nickwinder commented 11 months ago

@chschrae This is a great answer, thanks! I'll see what I can spin up.

chschrae commented 11 months ago

small edit that my bundle_path variable is passed down to the JNI from the app to tell it which directory I put the dlls in

nickwinder commented 11 months ago

I'm closing this as @chschrae idea seems the most viable.