iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📋

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

  1. 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. The buildFromSource setting is mandatory.
  2. Unused event listeners still have a cost — The event listener in expo-clipboard is always running on the Native side, even if not subscribed to in the app.
  3. 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, and hasStringAsync are unaffected.
GitHubで編集を提案

Discussion