| 1 | const { withMainActivity } = require("expo/config-plugins"); |
| 2 | |
| 3 | // predictiveBackGestureEnabled writes android:enableOnBackInvokedCallback="true", |
| 4 | // which retires the legacy KEYCODE_BACK/onBackPressed() delivery on Android 13+. |
| 5 | // From then on back gestures only reach the app through OnBackPressedDispatcher |
| 6 | // callbacks. react-native 0.85 registers its own always-enabled callback, but |
| 7 | // only on Android 16 with targetSdk 36 (ReactActivity's enforced-predictive-back |
| 8 | // workaround) — on Android 13-15 nothing is registered, the system consumes |
| 9 | // every back gesture itself, and JS back handling (React Navigation pops, |
| 10 | // BackHandler listeners) silently dies: each gesture just backgrounds the app. |
| 11 | // This plugin mirrors react-native's shim on API 33-35 so back keeps flowing |
| 12 | // to JS there. See https://github.com/software-mansion/react-native-screens/discussions/2540. |
| 13 | |
| 14 | const CALLBACK_PROPERTY = ` |
| 15 | // Routes predictive-back gestures to JS on Android 13-15, where react-native |
| 16 | // registers no OnBackPressedDispatcher callback of its own (it only does on |
| 17 | // Android 16+). Registered in onCreate; added by withAndroidPredictiveBackCompat. |
| 18 | private val predictiveBackCompatCallback = object : OnBackPressedCallback(true) { |
| 19 | override fun handleOnBackPressed() { |
| 20 | isEnabled = false |
| 21 | onBackPressed() |
| 22 | isEnabled = true |
| 23 | } |
| 24 | } |
| 25 | `; |
| 26 | |
| 27 | const CALLBACK_REGISTRATION = ` |
| 28 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && Build.VERSION.SDK_INT < Build.VERSION_CODES.BAKLAVA) { |
| 29 | onBackPressedDispatcher.addCallback(this, predictiveBackCompatCallback) |
| 30 | }`; |
| 31 | |
| 32 | // Wraps the template's invokeDefaultOnBackPressed: the default action ends in |
| 33 | // ComponentActivity.onBackPressed(), which re-enters the dispatcher — with the |
| 34 | // compat callback still enabled that would bounce back into JS forever instead |
| 35 | // of backgrounding the app. |
| 36 | const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() { |
| 37 | predictiveBackCompatCallback.isEnabled = false |
| 38 | try { |
| 39 | invokeDefaultOnBackPressedLegacy() |
| 40 | } finally { |
| 41 | predictiveBackCompatCallback.isEnabled = true |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | private fun invokeDefaultOnBackPressedLegacy() {`; |
| 46 | |
| 47 | function insertAfter(contents, anchor, insertion, description) { |
| 48 | const index = contents.indexOf(anchor); |
| 49 | if (index === -1) { |
| 50 | throw new Error( |
| 51 | `withAndroidPredictiveBackCompat: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`, |
| 52 | ); |
| 53 | } |
| 54 | const end = index + anchor.length; |
| 55 | return contents.slice(0, end) + insertion + contents.slice(end); |
| 56 | } |
| 57 | |
| 58 | module.exports = function withAndroidPredictiveBackCompat(config) { |
| 59 | if (config.android?.predictiveBackGestureEnabled !== true) { |
| 60 | return config; |
| 61 | } |
| 62 | |
| 63 | return withMainActivity(config, (nextConfig) => { |
| 64 | let contents = nextConfig.modResults.contents; |
| 65 | if (nextConfig.modResults.language !== "kt") { |
| 66 | throw new Error("withAndroidPredictiveBackCompat: MainActivity must be Kotlin."); |
| 67 | } |
| 68 | if (contents.includes("predictiveBackCompatCallback")) { |
| 69 | return nextConfig; |
| 70 | } |
| 71 | |
| 72 | contents = insertAfter( |
| 73 | contents, |
| 74 | "import android.os.Bundle", |
| 75 | "\nimport androidx.activity.OnBackPressedCallback", |
| 76 | "the android.os.Bundle import", |
| 77 | ); |
| 78 | contents = insertAfter( |
| 79 | contents, |
| 80 | "class MainActivity : ReactActivity() {", |
| 81 | CALLBACK_PROPERTY, |
| 82 | "the MainActivity class declaration", |
| 83 | ); |
| 84 | contents = insertAfter( |
| 85 | contents, |
| 86 | "super.onCreate(null)", |
| 87 | CALLBACK_REGISTRATION, |
| 88 | "the super.onCreate call", |
| 89 | ); |
| 90 | |
| 91 | if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) { |
| 92 | throw new Error( |
| 93 | "withAndroidPredictiveBackCompat: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.", |
| 94 | ); |
| 95 | } |
| 96 | contents = contents.replace( |
| 97 | "override fun invokeDefaultOnBackPressed() {", |
| 98 | INVOKE_DEFAULT_WRAPPER, |
| 99 | ); |
| 100 | |
| 101 | nextConfig.modResults.contents = contents; |
| 102 | return nextConfig; |
| 103 | }); |
| 104 | }; |