Unity with Android Activity Callbacks

Unity with Android Activity Callbacks

I am still learning android development, still wrapping my head around the basic concepts, so trying to get unity and android working together has had its challenges. One such challenge is triggering android activities from unity then getting some result back.

An Activity is a core building block of Android apps, representing a single screen with a user interface. Activities can start other activities using Intent, and can optionally get results back with startActivityForResult.

The end goal of this exercise was to trigger android’s native file picker, and to return the file uri to unity. This is why the classes will be named as FilePicker.x even for the “non-file picking” examples.

A common approach to this is to extend and customize UnityPlayerActivity and this allows you to handle callbacks directly in the custom unity activity. I wanted to avoid this as I will have multiple plugins and do not want to rely on a single class for all the callbacks. Another reason is that the UnityPlayerActivity is the entrypoint to the application and wanted it to have this simple responsiblity without coupling it with unrelated logic.

I had a simple goal to begin with, to get android to log a message that I can see in logcat. This doesn’t even require an activity so it was a good place to start.

Simple One Way Call

I created an android library module and added the kotlin code, which is just a simple non-activity class

package com.emu.samplelibrary

class FilePickerActivity {

    fun callHelloAndroid(): String {
        return "Hello World"
    }
}

And the AndroidManifest.xml is empty

<?xml version="1.0" encoding="utf-8"?>
<manifest/>

We then build the android app and fetch the .aar file from the ouput and copy it to Assets/Plugins/Android.

The c# code is a simple MonoBehaviour that calls the Android function onStart

  public class AndroidFilePicker : MonoBehaviour, IFilePicker
  {
    private AndroidJavaObject activity;

    void Start()
    {
      if (Application.platform == RuntimePlatform.Android)
      {
        using (AndroidJavaClass unityPlayer = new("com.unity3d.player.UnityPlayer"))
        {
          activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
        }
      }

      CallHelloAndroid();
    }

    public void CallHelloAndroid()
    {
      using (AndroidJavaObject filePickerInstance = new("com.emu.samplelibrary.FilePickerActivity"))
      {
        filePickerInstance.Call("callHelloAndroid");
      }
    }
  }

This is the general pattern for interacting with our android plugins, we first get the unity player activity, unity uses the base Activity class in this case so doesn’t come with androidx functionality. After that we get an AndroidJavaObject representing the kotlin FilePickerActivity and call its method callHelloAndroid.

After deploying to an android device logcat prints the expected output.

2024-11-29 18:04:51.380  4548-4582  FilePickerActivity      com.io.emu.Emu                       I  Hello Android

Calling Custom Activities from Unity

We next want to take this a step further, if we make FilePickerActivity an Activity, remember we have unity as the calling activity, and from what I understand of android activities usually start other activities. Our goal here is to have our unity activity start our custom android plugin activity.

We begin by making some changes to convert our basic class to an Activity

class FilePickerActivity : Activity() {

    companion object {
        private const val TAG = "FilePickerActivity"
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.i(TAG, "{$this} launched")
        callHelloAndroid()
    }

    fun startFilePickerActivity(parentActivity: Activity) {
        val parentIntent = Intent(parentActivity, FilePickerActivity::class.java)
        Log.i(TAG, "Launching FilePickerActivity")
        parentActivity.startActivity(parentIntent)
    }

    fun callHelloAndroid() {
        Log.i(TAG, "Hello Android")
    }
}

The main point of interest here is that we take the parentActivity as an argument to startFilePickerActivity. This will be the method called from unity and we will pass the unity activity as the parent.

We also will need to register the activity in AndroidManifest.xml, and even though this is a library it does need to be nested in the application tag

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <application>
        <activity android:name=".FilePickerActivity"/>
    </application>

</manifest>

If we look back at the kotlin code, this line

val parentIntent = Intent(parentActivity, FilePickerActivity::class.java)

creates an intent to start our FilePickerActivity and tells android to launch it from the unity activity. After we call startActivity(parentIntent) on this it will start our custom activity triggering the onCreate entry point and from there will trigger callHelloAndroid

2024-11-30 11:36:52.954 11408-11408 FilePickerActivity      com.io.emu.Emu                       I  Hello Android

The unity code is almost identical to previously except we are calling the startFilePickerActivity method and passing the unity activity as an argument

public class AndroidFilePicker : MonoBehaviour, IFilePicker
  {
    private AndroidJavaObject activity;

    void Start()
    {
      if (Application.platform == RuntimePlatform.Android)
      {
        using (AndroidJavaClass unityPlayer = new("com.unity3d.player.UnityPlayer"))
        {
          activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
        }
      }

      CallHelloAndroid();
    }

    public void CallHelloAndroid()
    {
      using (AndroidJavaObject filePickerInstance = new("com.emu.samplelibrary.FilePickerActivity"))
      {
        filePickerInstance.Call("startFilePickerActivity", activity);
      }
    }
  }

Chaining Activity Calls

Okay so now we know how to call our custom activity from unity. What if we then want to call another activity within our android activity. This might be obvious given what we already know, but let’s go through it as we want to take small steps up to our final goal.

The reason we do a second hop instead of just triggering the third activity from the unity activity will come clear later.

The kotlin activity class is updated as follows

class FilePickerActivity : Activity() {

    companion object {
        private const val TAG = "FilePickerActivity"
        private const val FILE_PICKER_REQUEST_CODE = 42
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.i(TAG, "{$this} launched")

        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "*/*"
        }
        Log.i(TAG, "Launching native file picker")
        startActivityForResult(intent, FILE_PICKER_REQUEST_CODE)
    }

    fun startFilePickerActivity(parentActivity: Activity) {
        val parentIntent = Intent(parentActivity, FilePickerActivity::class.java)
        Log.i(TAG, "Launching FilePickerActivity")
        parentActivity.startActivity(parentIntent)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == FILE_PICKER_REQUEST_CODE) {
            val filePath = data?.takeIf { resultCode == RESULT_OK }?.data?.toString().orEmpty()
            Log.i(TAG, "File picker result: $filePath")
            finish()
        }
    }
}

The first point of interest here is the onCreate method, we are using a built-in intent Intent.ACTION_OPEN_DOCUMENT which triggers the native file picker for android. We then pass call startActivityForResult which is a method of our base Activity so that android knows that the intent is being launched from our current activity rather than the unity one. As we are starting the activity “..forResult” it will expect a callback from the native file picker activity to our onActivityResult method. The FILE_PICKER_REQUEST_CODE is simply an identifier which is useful if we have more than one intent with callbacks.

The unity code hasn’t changed

  public class AndroidFilePicker : MonoBehaviour, IFilePicker
  {
    private AndroidJavaObject activity;

    void Start()
    {
      if (Application.platform == RuntimePlatform.Android)
      {
        using (AndroidJavaClass unityPlayer = new("com.unity3d.player.UnityPlayer"))
        {
          activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
        }
      }

      LaunchFilePicker();
    }

    public void LaunchFilePicker()
    {
      using (AndroidJavaObject filePickerInstance = new("com.emu.samplelibrary.FilePickerActivity"))
      {
        filePickerInstance.Call("startFilePickerActivity", activity);
      }
    }
  }

When we deploy our app now, it requires some user input, the unity splash screen will appear, then the unity scene, followed by the native file picker. We then need to select a file, after we do this control is passed back to our custom activity, and then finally passed back to the unity activity. The result is printed like so

2024-11-30 12:01:27.920 11906-11906 FilePickerActivity      com.io.emu.Emu                       I  File picker result: content://com.android.providers.media.documents/document/document%3A1000000018

Callbacks To Unity With Proxy Objects

Finally we have one more step to achieve our initial goal of selecting a file with the native android file picker and returning the file uri result to a unity c# class.

As we have been stepping gradually to this final goal there isn’t a lot of changes required to get the result back to our unity calling code. We begin with adding a new kotlin interface in the android plugin

interface FilePickerCallback {
    fun onFilePicked(filepath: String);
}

This interface is a description of what our callback looks like in the c# proxy class. The c# proxy class is as follows

  public class FilePickerCallbackProxy : AndroidJavaProxy
  {
    public FilePickerCallbackProxy() : base("com.emu.samplelibrary.FilePickerCallback") { }

    public void onFilePicked(string filePath)
    {
      if (string.IsNullOrEmpty(filePath))
      {
        EmuLogger.Info("File picker canceled or no file selected.");
      }
      else
      {
        EmuLogger.Info($"File selected: {filePath}");
      }
    }
  }

You will notice that we are extending the AndroidJavaProxy and passing the fully qualified class of our android interface. This essentially allows us to implement an android interface in our c# code. With this approach we can pass this implementation to our android plugin and the callback will trigger our c# code.

The FilePickerActivity is mostly the same as the previous iteration, except we are passing the AndroidJavaProxy and executing a callback to it when we get the result from the native file picker.

class FilePickerActivity : Activity() {

    companion object {
        private const val TAG = "FilePickerActivity"
        private const val FILE_PICKER_REQUEST_CODE = 42
        private var resultCallback: FilePickerCallback? = null
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Log.i(TAG, "{$this} launched")

        val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
            addCategory(Intent.CATEGORY_OPENABLE)
            type = "*/*"
        }
        Log.i(TAG, "Launching native file picker")
        startActivityForResult(intent, FILE_PICKER_REQUEST_CODE)
    }

    fun openFilePicker(parentActivity: Activity, unityCallback: FilePickerCallback?) {
        val parentIntent = Intent(parentActivity, FilePickerActivity::class.java)
        Log.i(TAG, "Launching FilePickerActivity")
        resultCallback = unityCallback;
        parentActivity.startActivity(parentIntent)
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        if (requestCode == FILE_PICKER_REQUEST_CODE) {
            val filePath = data?.takeIf { resultCode == RESULT_OK }?.data?.toString().orEmpty()
            Log.i(TAG, "File picker result: $filePath")
            resultCallback?.onFilePicked(filePath)
            finish()
        }
    }
}

The c# code is also very similar except we are initializing and passing the FilePickerCallbackProxy

  public class AndroidFilePicker : MonoBehaviour, IFilePicker
  {
    private AndroidJavaObject activity;

    void Start()
    {
      if (Application.platform == RuntimePlatform.Android)
      {
        using (AndroidJavaClass unityPlayer = new("com.unity3d.player.UnityPlayer"))
        {
          activity = unityPlayer.GetStatic<AndroidJavaObject>("currentActivity");
        }
      }

      LaunchFilePicker();
    }

    public void LaunchFilePicker()
    {
      FilePickerCallbackProxy callbackProxy = new();

      using (AndroidJavaObject filePickerInstance = new("com.emu.samplelibrary.FilePickerActivity"))
      {
        filePickerInstance.Call("openFilePicker", activity, callbackProxy);
      }
    }
  }

When we deploy our plugin now and select a file from the native android file picker it triggers a callback to the proxy object and gives us the output we expect

2024-12-01 17:24:22.493 26810-26810 Unity                   com.io.emu.Emu                       I  2024-12-01 17:24:22.4930 INFO (onFilePicked:19) File selected: content://com.android.providers.media.documents/document/document%3A1000000018

Conclusion

This process demonstrates how to achieve our original goal of getting results from Android to Unity, building up to it iteratively while avoiding the need to override Unity’s base activity.

One challenge with this approach is coordinating between the method that initiates the Android activity and the method that handles the callback in Unity. Unlike native plugins, where a function can directly return a value, working with activities requires handling results asynchronously. Even if you were to override Unity’s base activity, the result would still be delivered via a callback to a separate method, rather than as a direct return value.


Comments

Leave a Reply

Your email address will not be published. Required fields are marked *