Using an Activity from a Hilt ViewModel

Using an Activity from a Hilt ViewModel

πŸ‘‹ Hi, this is P.Y., I work as an Android Distinguished Engineer at Block. This blog shares a bit of hackery to be able to access an activity instance within a Hilt ViewModel. If you come up with other interesting ways to do this, let me know on Twitter! If you're mad because you think I'm encouraging bad practices, try yoga.

I need that god object

I've been playing with Hilt's support for view models in a small app, and needed my view model to start a sharing activity:

@HiltViewModel
class MyCuteLittleViewModel @Inject constructor(
) : ViewModel() {

  // ... some code that invokes share()

  private fun share(content: String) {
    val intent = Intent(Intent.ACTION_SEND).apply {
      type = "text/plain"
      putExtra(Intent.EXTRA_TEXT, content)
    }
    val chooserIntent = Intent.createChooser(intent, "Share with…")

    val activity = TODO("Need an activity here!")
    activity.startActivity(chooserIntent)
  }
}

View models are retained across activity config changes, so the activity isn't injectable, which makes total sense: injecting the activity in a view model would lead to leaks on config changes.

Unfortunately, the Activity class provides a lot of utility so it's fairly common to need access to it (see God object).

Most online resources recommend moving the code to the activity or a collaborator that has access to it, have that listen to events that indicate the action to perform, then send the events from the ViewModel.

You're not the boss of me.

I don't care for these "best" practices. I want that code right there where it's used, and I don't want unnecessary decoupling (also I pinky swear I'll write unit tests tomorrow).

Anyway, here's a little bit of Hilt hackery to support this without changing any Activity code.

First, let's create a CurrentActivityProvider scoped to @ActivityRetainedScoped, which will be in charge of holding the current activity instance:

@ActivityRetainedScoped
class CurrentActivityProvider @Inject constructor() {

  // TODO Set and clear currentActivity
  private var currentActivity: Activity? = null

  fun <T> withActivity(block: Activity.() -> T) : T {
    checkMainThread()
    val activity = currentActivity
    check(activity != null) {
      "Don't call this after the activity is finished!"
    }
    return activity.block()
  }
}

Then we can use it as needed. Notice that withActivity() makes it slightly harder to store the activity instance in the wrong place accidentally:

@HiltViewModel
class MyCuteLittleViewModel @Inject constructor(
  private val activityProvider: CurrentActivityProvider
) : ViewModel() {

  private fun share(content: String) {
    // ...
    activityProvider.withActivity {
      startActivity(chooserIntent)
    }
  }
}

Now we need to set up CurrentActivityProvider.currentActivity for each ActivityRetainedComponent scope. For that, we create an entry point scoped to the activity (ActivityComponent) which will provide access to the CurrentActivityProvider (which lives in a parent ActivityRetainedComponent scope). The entry point:

@EntryPoint
@InstallIn(ActivityComponent::class)
interface ActivityProviderEntryPoint {
  val activityProvider: CurrentActivityProvider
}

Now we can retrieve the scoped activity provider from an activity instance with:

val entryPoint: ActivityProviderEntryPoint =
  EntryPointAccessors.fromActivity(this)
val activityProvider = entryPoint.activityProvider

This only works if the activity is Hilt-aware, so let's check that it implements GeneratedComponentManagerHolder (🀫 it's in Hilt's internal package but it's also public so πŸ€·β€β™‚οΈ) and let's make a small Activity.withProvider() utility for that:

activity.withProvider { activityProvider ->
  // TODO
}

    private fun Activity.withProvider(
      block: CurrentActivityProvider.() -> Unit
    ) {
      if (this is GeneratedComponentManagerHolder) {
        val entryPoint: ActivityProviderEntryPoint =
          EntryPointAccessors.fromActivity(this)
        val provider = entryPoint.activityProvider
        provider.block()
      }
    }

Note: Android apps can have multiple activities in created state at the same time. The code here supports that by relying on the ActivityRetainedComponent scope which will give us a new component for each activity in the stack, but still return the same logical component when an activity is recreated through a config change.

Now let's add methods to update the activity reference on lifecycle changes:

@ActivityRetainedScoped
class CurrentActivityProvider @Inject constructor() {

  private var currentActivity: Activity? = null

  fun <T> withActivity(block: Activity.() -> T) : T { /* ... */  }

  companion object {
    private fun Activity.withProvider(
      block: CurrentActivityProvider.() -> Unit
    ) { /* ... */ }

    fun onActivityCreated(activity: Activity) {
      activity.withProvider {
        currentActivity = activity
      }
    }

    fun onActivityDestroyed(activity: Activity) {
      activity.withProvider {
        if (currentActivity === activity) {
          currentActivity = null
        }
      }
    }
  }
}

And finally let's hook the lifecycle callbacks from my Application class:

@HiltAndroidApp
class MyCuteLittleApp : Application() {

  override fun onCreate() {
    super.onCreate()

    registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
      override fun onActivityCreated(
        activity: Activity,
        savedInstanceState: Bundle?
      ) {
        CurrentActivityProvider.onActivityCreated(activity)
      }

      override fun onActivityDestroyed(activity: Activity) {
        CurrentActivityProvider.onActivityDestroyed(activity)
      }
    })
  }
}

With this, we can now inject CurrentActivityProvider in any ActivityRetainedComponent scope (as well as lower scopes) and easily access the activity with activityProvider.withActivity().

Testing testing 1 2 3 🎀

To make MyCuteLittleViewModel easier to test we can move the sharing responsibility to an injected collaborator, e.g. Sharer:

interface Sharer {
  fun share(content: String)
}

class ActivitySharer @Inject constructor(
  private val activityProvider: CurrentActivityProvider
) : Sharer {
  override fun share(content: String) {
    // ...
    activityProvider.withActivity {
      startActivity(chooserIntent)
    }
  }
}

@Module
@InstallIn(ActivityRetainedComponent::class)
interface SharerModule {
  @Binds fun bindSharer(sharer: ActivitySharer): Sharer
}

Header image generated by DALL-E, prompt: "sword held by an Android with glasses, 3d render"

Β