Using an Activity from a Hilt ViewModel

Search for a command to run...

My first thought was that the provider would implement the activity lifecycle callback. So I would just have one line to register it.
However, when you have multiple of them, maybe this is better so you can control the order of the calls or what is the intent?
Yeah the general idea here is that this ensures you add an activity to a specific activity stack, rather than starting it from a random live activity or from the application context which isn't tied to a specific activity stack.
Hi Py,
I don't get your blog post to start an activity you don't have to have an activity right? a context is enough as it is possible to call context.startActivity
Thanks, Flo
Unfortunately, context is enough in limited cases.
Starting an activity from the Application class isn't the same as starting it from an activity. When using the application class the activity manager doesn't know which activity stack the new activity should be added to.
Exactly one year ago, after 12 years in San Francisco, I moved back to France. Life since has been a whirlwind: selling the condo, moving a container overseas, rediscovering French culture (I had to r

The SQLDelight documentation provides this example: val players: Flow<List<HockeyPlayer>> = playerQueries.selectAll() .asFlow() .mapToList(Dispatchers.IO) This looks reasonable, right? In the Square Point Of Sale application, we recently ...

A failed experiment with dominator trees

In this article I run the new LeakCanary toolkit against the Slack Android app. Read on to learn a bunch! A new LeakCanary toolkit In two months, I will give a talk at Droidcon SF: Cutting Edges: universal heap trimming with LeakCanary 3 At Square, ...

Today I was mob programming with Square's Mobile & Performance Reliability team and we toyed with an interesting idea. Our codebase has classes that represent screens a user can navigate to. These classes are defined in modules, and these modules hav...

π Hi, this is P.Y., I work as an Android 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'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.
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
ActivityRetainedComponentscope 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().
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"