1const { 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
14const 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
27const 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.
36const 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
47function 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
58module.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};