Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@ const config: ExpoConfig = {
"./plugins/withAndroidModernPopupMenu.cjs",
"./plugins/withAndroidModernAlertDialog.cjs",
"./plugins/withAndroidPredictiveBackCompat.cjs",
"./plugins/withAndroidNativeScreenBack.cjs",
"./plugins/withAndroidTabletOrientation.cjs",
...(isIosPersonalTeamBuild ? ["./plugins/withoutIosPersonalTeamCapabilities.cjs"] : []),
],
Expand Down
159 changes: 159 additions & 0 deletions apps/mobile/plugins/withAndroidNativeScreenBack.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
const { withMainActivity } = require("expo/config-plugins");

// Android back normally reaches the navigator through JS: the press is queued
// on the JS thread, React Navigation pops, and only then does the screen stack
// animate. While JS is busy (a thread loading or syncing) back does nothing
// until that work finishes. This callback pops the top screen on the UI thread
// instead when the screen opted in (native-stack's
// unstable_nativeBackDismissalEnabled), the same native dismissal an iOS swipe
// back uses: JS learns of it through onDismissed and updates its state then.
// Everything else still goes to JS, including back while in-window UI that
// handles back in JS is on screen (marked with JS_BACK_HANDLER_NATIVE_ID).

const JS_BACK_HANDLER_NATIVE_ID = "t3-js-back-handler";

const IMPORTS = `
import android.view.View
import android.view.ViewGroup
import com.facebook.react.uimanager.util.ReactFindViewUtil
import com.swmansion.rnscreens.ScreenStack
import com.swmansion.rnscreens.ScreenStackFragmentWrapper`;

const CALLBACK_PROPERTY = `
// Pops an opted-in screen without waiting for JS; everything else is handed
// on to React Native's callback. Registered in onPostCreate, after React
// Native's own, so it runs first; added by withAndroidNativeScreenBack.
private val nativeScreenBackCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
val target = nativeScreenBackTarget()
if (target != null) {
target.dismissFromContainer()
return
}
isEnabled = false
try {
onBackPressedDispatcher.onBackPressed()
} finally {
isEnabled = true
}
}
}

override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
onBackPressedDispatcher.addCallback(this, nativeScreenBackCallback)
}

// The top screen of the innermost stack on screen, if it may be popped natively.
private fun nativeScreenBackTarget(): ScreenStackFragmentWrapper? {
// Wide layouts show more than one stack at a time; leave those to JS.
if (resources.configuration.smallestScreenWidthDp >= 600) return null
// In-window UI that handles back in JS (an open menu) closes first. It is
// marked on the view itself, so the check holds from its first frame.
if (ReactFindViewUtil.findView(window.decorView, JS_BACK_HANDLER_NATIVE_ID) != null) return null
var stack = findScreenStack(window.decorView, Int.MAX_VALUE) ?: return null
while (true) {
val top = stack.topScreen ?: return null
val nested = findScreenStack(top, NESTED_SCREEN_STACK_DEPTH)
if (nested?.topScreen != null) {
stack = nested
continue
}
if (!top.nativeBackButtonDismissalEnabled || stack.rootScreen === top) return null
return top.fragmentWrapper as? ScreenStackFragmentWrapper
}
}

private fun findScreenStack(root: View, maxDepth: Int): ScreenStack? {
val views = ArrayDeque<View>()
val depths = ArrayDeque<Int>()
views.addLast(root)
depths.addLast(0)
while (views.isNotEmpty()) {
val view = views.removeFirst()
val depth = depths.removeFirst()
if (view !== root && view is ScreenStack) return view
if (view is ViewGroup && depth < maxDepth) {
for (index in 0 until view.childCount) {
views.addLast(view.getChildAt(index))
depths.addLast(depth + 1)
}
}
}
return null
}

private companion object {
// A nested navigator's stack sits a few views inside its screen.
const val NESTED_SCREEN_STACK_DEPTH = 10
// Matches src/lib/androidNativeBack.ts.
const val JS_BACK_HANDLER_NATIVE_ID = "${JS_BACK_HANDLER_NATIVE_ID}"
}
`;

// The default action ends in ComponentActivity.onBackPressed(), which
// re-enters the dispatcher; with this callback enabled it would be handed
// straight back to JS instead of backgrounding the app.
const INVOKE_DEFAULT_WRAPPER = `override fun invokeDefaultOnBackPressed() {
nativeScreenBackCallback.isEnabled = false
try {
invokeDefaultOnBackPressedAfterNativeScreenBack()
} finally {
nativeScreenBackCallback.isEnabled = true
}
}

private fun invokeDefaultOnBackPressedAfterNativeScreenBack() {`;

function insertAfter(contents, anchor, insertion, description) {
const index = contents.indexOf(anchor);
if (index === -1) {
throw new Error(
`withAndroidNativeScreenBack: could not find ${description} in MainActivity — the Expo template changed; update the plugin anchors.`,
);
}
const end = index + anchor.length;
return contents.slice(0, end) + insertion + contents.slice(end);
}

module.exports = function withAndroidNativeScreenBack(config) {
return withMainActivity(config, (nextConfig) => {
let contents = nextConfig.modResults.contents;
if (nextConfig.modResults.language !== "kt") {
throw new Error("withAndroidNativeScreenBack: MainActivity must be Kotlin.");
}
if (contents.includes("nativeScreenBackCallback")) {
return nextConfig;
}

let imports = IMPORTS;
if (!contents.includes("import androidx.activity.OnBackPressedCallback")) {
imports = `\nimport androidx.activity.OnBackPressedCallback${imports}`;
}
contents = insertAfter(
contents,
"import android.os.Bundle",
imports,
"the android.os.Bundle import",
);
contents = insertAfter(
contents,
"class MainActivity : ReactActivity() {",
CALLBACK_PROPERTY,
"the MainActivity class declaration",
);

if (!contents.includes("override fun invokeDefaultOnBackPressed() {")) {
throw new Error(
"withAndroidNativeScreenBack: could not find invokeDefaultOnBackPressed in MainActivity — the Expo template changed; update the plugin anchors.",
);
}
contents = contents.replace(
"override fun invokeDefaultOnBackPressed() {",
INVOKE_DEFAULT_WRAPPER,
);

nextConfig.modResults.contents = contents;
return nextConfig;
});
};
86 changes: 86 additions & 0 deletions apps/mobile/plugins/withAndroidNativeScreenBack.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { JS_BACK_HANDLER_NATIVE_ID } from "../src/lib/androidNativeBack";
import withAndroidNativeScreenBack from "./withAndroidNativeScreenBack.cjs";
import withAndroidPredictiveBackCompat from "./withAndroidPredictiveBackCompat.cjs";

const mainActivity = `package com.t3tools.t3code
import android.os.Build
import android.os.Bundle

import com.facebook.react.ReactActivity

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

override fun invokeDefaultOnBackPressed() {
super.invokeDefaultOnBackPressed()
}
}`;

async function transform(plugin, contents, extraConfig = {}) {
const config = plugin({ name: "Test", slug: "test", ...extraConfig });
const result = await config.mods.android.mainActivity({
...config,
modRequest: { platform: "android", modName: "mainActivity", introspect: false },
modResults: { language: "kt", contents },
});
return result.modResults.contents;
}

describe("Android native screen back generation", () => {
it("registers the callback after onCreate and dismisses opted-in screens natively", async () => {
const result = await transform(withAndroidNativeScreenBack, mainActivity);
expect(result).toContain("import androidx.activity.OnBackPressedCallback");
expect(result).toContain("import com.swmansion.rnscreens.ScreenStack");
expect(result).toContain("override fun onPostCreate(savedInstanceState: Bundle?) {");
expect(result).toContain("onBackPressedDispatcher.addCallback(this, nativeScreenBackCallback)");
expect(result).toContain("target.dismissFromContainer()");
expect(result).toContain("top.nativeBackButtonDismissalEnabled");
});

it("leaves back to JS while in-window UI marked for JS back is on screen", async () => {
const result = await transform(withAndroidNativeScreenBack, mainActivity);
expect(result).toContain("import com.facebook.react.uimanager.util.ReactFindViewUtil");
expect(result).toContain(
"if (ReactFindViewUtil.findView(window.decorView, JS_BACK_HANDLER_NATIVE_ID) != null) return null",
);
expect(result).toContain(
`const val JS_BACK_HANDLER_NATIVE_ID = "${JS_BACK_HANDLER_NATIVE_ID}"`,
);
});

it("turns the callback off while the default back action runs", async () => {
const result = await transform(withAndroidNativeScreenBack, mainActivity);
const wrapper = result.slice(result.indexOf("override fun invokeDefaultOnBackPressed() {"));
expect(wrapper.indexOf("nativeScreenBackCallback.isEnabled = false")).toBeLessThan(
wrapper.indexOf("invokeDefaultOnBackPressedAfterNativeScreenBack()"),
);
expect(result).toContain(
"private fun invokeDefaultOnBackPressedAfterNativeScreenBack() {\n super.invokeDefaultOnBackPressed()",
);
});

it("wraps the predictive back compat wrapper instead of replacing it", async () => {
const compat = await transform(withAndroidPredictiveBackCompat, mainActivity, {
android: { predictiveBackGestureEnabled: true },
});
const result = await transform(withAndroidNativeScreenBack, compat);
expect(result.match(/import androidx\.activity\.OnBackPressedCallback/g)).toHaveLength(1);
expect(result).toContain("predictiveBackCompatCallback.isEnabled = false");
expect(result).toContain("nativeScreenBackCallback.isEnabled = false");
expect(result).toContain("private fun invokeDefaultOnBackPressedLegacy() {");
});

it("does not duplicate code on subsequent prebuilds", async () => {
const generated = await transform(withAndroidNativeScreenBack, mainActivity);
expect(await transform(withAndroidNativeScreenBack, generated)).toBe(generated);
});

it("fails visibly when the Expo MainActivity template changes", async () => {
await expect(
transform(withAndroidNativeScreenBack, "import android.os.Bundle\nclass MainActivity {}"),
).rejects.toThrow("could not find the MainActivity class declaration");
});
});
4 changes: 3 additions & 1 deletion apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,9 @@ const RootStackConfig = createNativeStackNavigator({
Thread: createNativeStackScreen({
screen: ThreadRouteScreen,
linking: THREAD_LINKING_PREFIX,
options: GLASS_HEADER_OPTIONS,
// Android back pops the thread without waiting for JS; see
// withAndroidNativeScreenBack.
options: { ...GLASS_HEADER_OPTIONS, unstable_nativeBackDismissalEnabled: true },
}),
ThreadTerminal: createNativeStackScreen({
screen: ThreadTerminalRouteScreen,
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/AndroidAnchoredMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BackHandler, Pressable, ScrollView, View } from "react-native";
import { useKeyboardState } from "react-native-keyboard-controller";
import Animated, { FadeIn } from "react-native-reanimated";

import { JS_BACK_HANDLER_NATIVE_ID } from "../lib/androidNativeBack";
import { OverlayPortal } from "./OverlayPortal";
import { useAndroidControlSizing } from "./useAndroidControlSizing";
import { MaterialMenuPopup } from "./MaterialMenuPopup";
Expand Down Expand Up @@ -193,10 +194,13 @@ export function AndroidAnchoredMenu(props: AndroidAnchoredMenuProps) {
)}
{anchor === null ? null : (
<OverlayPortal>
{/* The nativeID keeps native back (withAndroidNativeScreenBack) from
popping the screen while the in-window menu takes back. */}
<View
ref={overlayRef}
collapsable={false}
className="absolute inset-0"
nativeID={anchor.keyboardWasVisible ? JS_BACK_HANDLER_NATIVE_ID : undefined}
onLayout={measureOverlay}
>
<Pressable accessible={false} className="absolute inset-0" onPress={close} />
Expand Down
6 changes: 6 additions & 0 deletions apps/mobile/src/lib/androidNativeBack.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* nativeID for in-window UI that handles Android back in JS. While a view with
* this ID is on screen, withAndroidNativeScreenBack hands back to JS instead of
* popping the screen natively, so back closes that UI first.
*/
export const JS_BACK_HANDLER_NATIVE_ID = "t3-js-back-handler";
Loading
Loading