iTranslated by AI
Fixing Android ANR in expo-clipboard's ClipboardEventEmitter using patch-package
Introduction
A Android ANR (Application Not Responding) was reported in Crashlytics for an Expo (React Native) app. The cause was that the ClipboardEventEmitter inside expo-clipboard was calling a synchronous Binder IPC (getPrimaryClipDescription()) on the main thread.
In this article, I will explain the steps from identifying the cause to applying a patch.
The Problem: Main Thread Blocked by ClipboardEventEmitter
Symptoms
An ANR stack trace like the following is reported to Crashlytics.
main (blocked):
android.os.BinderProxy.transactNative (Native method)
android.content.ClipboardManager.getPrimaryClipDescription
expo.modules.clipboard.ClipboardModule$ClipboardEventEmitter.<init>
The main thread was blocked by a synchronous Binder IPC call to ClipboardManager.getPrimaryClipDescription(), and since there was no response for more than 5 seconds, an ANR occurred.
Cause
The Android implementation of expo-clipboard (ClipboardModule.kt) includes a ClipboardEventEmitter that monitors clipboard changes.
// expo-clipboard's ClipboardModule.kt (simplified)
OnCreate {
clipboardEventEmitter = ClipboardEventEmitter()
clipboardEventEmitter.attachListener()
}
private inner class ClipboardEventEmitter {
fun attachListener() =
clipboardManager?.addPrimaryClipChangedListener(listener)
private val listener = OnPrimaryClipChangedListener {
// Calling getPrimaryClipDescription()
// -> Blocks the main thread with synchronous Binder IPC
clipboardManager?.primaryClipDescription?.let { clip ->
sendEvent("onClipboardChanged", ...)
}
}
// Accessing clipboardManager in the constructor
// -> Binder IPC occurs here as well
private val maybeClipboardManager =
runCatching { clipboardManager }.getOrNull()
}
This issue becomes apparent when the following conditions overlap:
| Condition | Impact |
|---|---|
| Other apps frequently update the clipboard | The listener fires frequently |
| System clipboard service is under high load | Binder IPC latency increases |
| Main thread is rendering UI | Likely to reach the 5-second threshold for ANR determination |
Fix
If the app only uses Clipboard.setStringAsync() and clipboard change events are unnecessary, the simplest solution is to delete the ClipboardEventEmitter entirely.
Step 1: Configure buildFromSource
Since expo-clipboard uses pre-built AAR (.aar) by default, patches to the Kotlin source code will not be reflected. Add the following to package.json to force building from source.
{
"expo": {
"autolinking": {
"android": {
"buildFromSource": ["expo-clipboard"]
}
}
}
}
Step 2: Delete Listener Code from ClipboardModule.kt
Edit node_modules/expo-clipboard/android/src/main/java/expo/modules/clipboard/ClipboardModule.kt to delete code related to event listeners.
- import android.os.Build
import android.text.Html
import android.text.Html.FROM_HTML_MODE_LEGACY
import android.text.Spanned
import android.text.TextUtils
- import android.util.Log
- import androidx.core.os.bundleOf
import expo.modules.core.utilities.ifNull
private const val moduleName = "ExpoClipboard"
- private val TAG = ClipboardModule::class.java.simpleName
const val CLIPBOARD_DIRECTORY_NAME = ".clipboard"
- const val CLIPBOARD_CHANGED_EVENT_NAME = "onClipboardChanged"
-
- private enum class ContentType(val jsName: String) {
- PLAIN_TEXT("plain-text"),
- HTML("html"),
- IMAGE("image")
- }
Lifecycle hooks within ModuleDefinition:
clipboardManager.primaryClipDescription?.hasMimeType("image/*") == true
}
-
- Events(CLIPBOARD_CHANGED_EVENT_NAME)
-
- OnCreate {
- clipboardEventEmitter = ClipboardEventEmitter()
- clipboardEventEmitter.attachListener()
- }
-
- OnDestroy {
- clipboardEventEmitter.detachListener()
- }
-
- OnActivityEntersBackground {
- clipboardEventEmitter.pauseListening()
- }
-
- OnActivityEntersForeground {
- clipboardEventEmitter.resumeListening()
- }
}
The entire ClipboardEventEmitter inner class:
- private lateinit var clipboardEventEmitter: ClipboardEventEmitter
-
- private inner class ClipboardEventEmitter {
- private var isListening = true
- private var timestamp = -1L
- fun resumeListening() { isListening = true }
- fun pauseListening() { isListening = false }
-
- fun attachListener() =
- maybeClipboardManager?.addPrimaryClipChangedListener(listener)
- .ifNull {
- Log.e(TAG, "'CLIPBOARD_SERVICE' unavailable.")
- }
-
- fun detachListener() =
- maybeClipboardManager?.removePrimaryClipChangedListener(listener)
-
- private val listener = OnPrimaryClipChangedListener {
- // ... Event sending logic (omitted)
- }
-
- private val maybeClipboardManager =
- runCatching { clipboardManager }.getOrNull()
- }
Step 3: Generate the Patch File
npx patch-package expo-clipboard
patches/expo-clipboard+7.1.5.patch will be generated. If patch-package is set in the postinstall script of package.json, it will be applied automatically on subsequent npm install runs.
{
"scripts": {
"postinstall": "patch-package"
}
}
Step 4: Build and Verify
# prebuild (regenerate the android directory)
npx expo prebuild --clean --platform android
# Build and run
npx expo run:android
Confirm in the build logs that expo-clipboard is being compiled from source:
> Task :expo-clipboard:compileDebugKotlin
> Task :expo-clipboard:compileDebugJavaWithJavac
Summary
| Item | Content |
|---|---|
| Issue |
ClipboardEventEmitter executes synchronous Binder IPC on the main thread, causing an ANR |
| Cause | The event listener is unconditionally registered during module initialization even if unused |
| Fix | Delete ClipboardEventEmitter related code and fix it with patch-package
|
Learnings
-
Be careful with Expo module AAR builds — Even if you fix the Kotlin source with
patch-package, the changes won't be reflected if the AAR takes precedence. ThebuildFromSourcesetting is mandatory. -
Unused event listeners still have a cost — The event listener in
expo-clipboardis always running on the Native side, even if not subscribed to in the app. - Read Crashlytics ANR stack traces — While ANRs are hard to reproduce, it is possible to identify blocking calls from the stack trace.
Points of Caution
- The patch is version-specific (e.g.,
expo-clipboard@7.1.5). You will need to regenerate the patch when updating the SDK. - The
Clipboard.addClipboardListener()API on the JS side will remain, but since the Native-side event emission is deleted, it will become a no-op on Android. - Read/write APIs like
setStringAsync,getStringAsync, andhasStringAsyncare unaffected.
Discussion