matin@dev~/blog/activity-result-api-for-react-native-modules.mdx~/blogzsh

cat activity-result-api-for-react-native-modules.mdx

React Native Modules Cannot Use Android's Activity Result API

Technology··5 min

#react-native#android#open-source

If you maintain an Android library for React Native, you have probably met this crash:

LifecycleOwner ... is attempting to register while current state is RESUMED.
LifecycleOwners must call register before they are STARTED.

It shows up the moment you try to do something ordinary: ask AndroidX to start an Activity for you and hand back the result. I hit it in react-native-health-connect, and after the third workaround I stopped trying to route around it and wrote the missing piece instead.

The modern Android API is closed to us

Android moved away from startActivityForResult years ago. The replacement is the Activity Result API: you register a contract, you get a launcher, you call launch(), your callback fires with a typed result.

private val getContent = registerForActivityResult(GetContent()) { uri -> ... }

That line works in an Activity. It does not work in a React Native module, and the reason is timing. AndroidX only accepts a registration before the Activity reaches STARTED, because a result can arrive after the process was killed and restored, and the registry must know every callback before it starts delivering. Native modules are created lazily, whenever JavaScript first touches them, which is long after the Activity resumed. So getCurrentActivity()?.registerForActivityResult(...) always lands too late and throws.

The old ActivityEventListener still works, so most modules stay there. But it has its own problem: you pick your own integer request code, out of a namespace no one coordinates, and hope no other library picked the same number.

And for some contracts there is no fallback at all. Health Connect’s permission contract has no startActivityForResult equivalent. If you cannot call registerForActivityResult, you cannot request permissions. That is the whole feature, blocked on a timing rule. This has been open as #33639 since 2022.

Every workaround leaks into the app

Library authors have found two ways out, and both cost the consumer something.

The first is glue code in MainActivity. This is what react-native-health-connect does today: every app that installs it must add a line to its own Activity so the library can register early enough.

class MainActivity : ReactActivity() {
  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    HealthConnectPermissionDelegate.setPermissionDelegate(this)
  }
}

A one-line install step, except it is not: it is a native file edit, in a file that autolinking is supposed to keep you out of, and a support thread every time someone forgets it.

The second is shipping a transparent Activity in the library’s manifest, which I prototyped. It removes the setup step, but it puts a second Activity in someone else’s app for the sole purpose of being a valid registration point. That cuts against Google’s own single-activity guidance, and it is a lot of machinery to stand in for a callback.

Expo solved this properly with registerActivityContracts. Bare React Native had no equivalent.

The registry was already there

Here is the part that made me write the PR. ReactActivity extends ComponentActivity. That means the host Activity already owns a real ActivityResultRegistry, and results already route into it. Nothing was missing at the bottom of the stack. The only thing missing was a way for a module to reach it.

So the API is a method on ReactContext, with the same shape as the one on ComponentActivity, plus an owner:

class MyModule(private val context: ReactApplicationContext) :
    NativeMyModuleSpec(context) {

  private var pendingPromise: Promise? = null

  private val requestPermission =
      context.registerForActivityResult(
          /* owner = */ this,
          ActivityResultContracts.RequestPermission()) { isGranted ->
        pendingPromise?.resolve(isGranted)
        pendingPromise = null
      }

  override fun requestCameraPermission(promise: Promise) {
    pendingPromise = promise
    requestPermission.launch(Manifest.permission.CAMERA)
  }
}

You register in a field initializer, at whatever moment the module happens to be created. The launcher you get back is not connected to anything yet. It connects to the real registry as soon as an Activity is available, and a launch() made before that is held and fired on connect. No MainActivity edit, no manifest entry, no new Gradle dependency, and ActivityEventListener keeps working exactly as before.

The interesting problems are underneath

The public surface is one method. Most of the work is in three places where the obvious implementation is wrong.

Keys cannot be counters. ComponentActivity numbers its registrations as they happen, which is safe because the order is fixed. Module order is not fixed: it depends on what JavaScript touched first. After a process restart, counter 3 could belong to a different module than it did before, and a restored result would reach the wrong callback. So keys are built from class names instead, as "<owner class>:<contract class>". Two libraries can register the same stock contract without colliding. The cost is that an anonymous object cannot be an owner, since its generated name changes between builds, and minified builds need -keepnames on the owner if results must survive an app update.

Reconnecting is not “am I connected?” With more than one Activity, the new one resumes before the old one is destroyed, and the old one’s onHostDestroy never runs, because currentActivity has already moved on. A launcher that only asked whether it was connected would stay bound to the dead Activity’s registry, hold it alive, and send launches from the new screen to the old one. So every resume checks each launcher against the current registry, not against nothing.

The registry is UI thread only, and does not say so. Nothing throws if you touch it from another thread. It just corrupts its internal maps, quietly. React Native arrives from the JS thread when modules register, and from the native modules thread when they launch, so every call that reaches the registry is forwarded to the UI thread. Collision detection stays on a concurrent map, so registration can still return your launcher immediately and throw on a duplicate from your own call.

One honest limitation: if the process was killed, AndroidX redelivers the pending result under the same key, but the Promise your module was holding died with the JavaScript context. Callbacks have to tolerate firing with nothing pending.

Where it stands

The PR is #57798, open at the time of writing, behind the demos in SampleTurboModule and two rn-tester screens.

What I keep thinking about is how long a gap can sit in a framework when every library can work around it. Seven libraries each shipping a MainActivity setup step looks like seven small install notes. It is really the same missing method, paid for seven times, by every app that installs them.