Skip to content

Commit 4662183

Browse files
authored
fix(onboarding): fill Android gesture inset without pushing fox under nav (MetaMask#35838)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> <!-- mms-check directive vocabulary — read by .github/scripts/shared/pr-template-checks.ts at module load to build the validation plan. Directives are invisible in rendered markdown and must NOT be removed or edited without updating the validator registry. type=text Section must contain non-placeholder prose. type=changelog Section must have a valid CHANGELOG entry: line. type=issue-link Section must have a Fixes:/Closes:/Refs: line with a value. type=manual-testing Section must have real testing steps or an explicit N/A. type=screenshot Section must have evidence (image/URL) or an explicit N/A. type=checklist Section must have all checkboxes consciously checked. required=true|false Whether a missing/invalid section runs the validator at all. blocking=true|false Whether a failure of this check fails the CI workflow. Default: false — failures are shown as warnings in the sticky comment but do not block the PR. Sections without a directive are checked for structural presence only. --> ## **Description** On Android (especially gesture navigation / Pixel), the onboarding cream/purple canvas stopped short of the system navigation / gesture area, leaving a white “paper cut” strip under the fox. With targetSdk 36 the nav bar is transparent over the window background, so SafeArea-bottom padding on a background-colored container made the gap more visible. <!-- mms-check: type=text required=true --> <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> ## **Changelog** <!-- mms-check: type=changelog required=true blocking=true --> <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed Android onboarding background not filling the gesture/navigation area under the fox ## **Related issues** <!-- mms-check: type=issue-link required=true --> Fixes: https://consensyssoftware.atlassian.net/browse/TO-1033 ## **Manual testing steps** <!-- mms-check: type=manual-testing required=true --> ```gherkin Feature: Onboarding Android bottom safe area / fox position Background: Given the app is freshly installed or reset to the welcome/onboarding screen And I am testing on Android Scenario: gesture navigation device shows continuous canvas under the fox Given the device uses gesture navigation (e.g. Pixel) And the app theme is light mode When user lands on the Onboarding welcome screen Then the cream onboarding background extends into the bottom gesture area And there is no white strip under the fox And the fox animation is fully visible above the gesture bar ``` ## **Screenshots/Recordings** <!-- mms-check: type=screenshot required=true --> <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <img width="263" height="565" alt="Screenshot 2026-09-08 at 2 47 10 PM" src="https://github.com/user-attachments/assets/25c92837-b483-4b06-b0fd-d9cd5fd828e6" /> <!-- [screenshots/recordings] --> ### **After** <img width="266" height="566" alt="Screenshot 2026-09-08 at 2 32 47 PM" src="https://github.com/user-attachments/assets/302c3432-66b4-4b2f-8808-7e2f9a173d1a" /> <img width="275" height="637" alt="Screenshot 2026-09-08 at 2 32 59 PM" src="https://github.com/user-attachments/assets/e05feca3-35a5-43ea-9b8b-30996da81c15" /> <img width="267" height="582" alt="Screenshot 2026-09-08 at 5 57 50 PM" src="https://github.com/user-attachments/assets/ba72e5c5-56b5-4ec9-97f4-0ccc6ca14978" /> <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** <!-- mms-check: type=checklist required=true --> <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Scoped to Android edge-to-edge and onboarding welcome layout/fox positioning; no auth, payments, or data-path changes, though edge-to-edge may have minor visual side effects elsewhere on Android. > > **Overview** > Fixes a visible white strip under the onboarding fox on Android (gesture nav / targetSdk 36) by letting the cream/purple canvas draw into the bottom system inset while keeping the fox above the gesture bar. > > **Android** turns on `edgeToEdgeEnabled` in `gradle.properties` so the window background can extend behind the transparent navigation area. > > **Onboarding layout** moves the themed background to a full-screen root `View`, uses `SafeAreaView` with only the top edge for scroll/CTAs, and places `FoxAnimation` as a sibling on that root canvas so it is not clipped by bottom safe-area padding. The onboarding stack screen gets matching `contentStyle` background and `headerShown: false`. > > **Fox positioning** simplifies `getSafeBottomPosition` in `FoxAnimation` (notably Android without footer uses `max(0, bottom inset)` instead of negative offsets). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 01fd236. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 40b6a62 commit 4662183

5 files changed

Lines changed: 280 additions & 118 deletions

File tree

android/gradle.properties

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ expo.useLegacyPackaging=false
5555
# Use this property to enable edge-to-edge display support.
5656
# This allows your app to draw behind system bars for an immersive UI.
5757
# Note: Only works with ReactActivity and should not be used with custom Activity.
58-
edgeToEdgeEnabled=false
58+
# Required for onboarding cream canvas / fox to reach the Android gesture area
59+
# (targetSdk 36 treats the nav bar as transparent over the window background).
60+
edgeToEdgeEnabled=true
5961
reactNativeDir=../node_modules/react-native
6062
REACT_NATIVE_DIR=../node_modules/react-native

app/components/Nav/App/App.tsx

Lines changed: 62 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -306,16 +306,27 @@ const OnboardingSuccessFlow = () => {
306306
* Create Wallet and Import from Secret Recovery Phrase
307307
*/
308308
const OnboardingNav = () => {
309-
const { colors } = useTheme();
309+
const { themeAppearance } = useTheme();
310+
const onboardingCanvasColor =
311+
themeAppearance === 'dark'
312+
? importedColors.gettingStartedTextColor
313+
: importedColors.gettingStartedPageBackgroundColorLightMode;
310314

311315
return (
312316
<NativeStack.Navigator
313317
initialRouteName={'Onboarding'}
314318
screenOptions={{
315-
contentStyle: { backgroundColor: colors.background.default },
319+
contentStyle: { backgroundColor: onboardingCanvasColor },
316320
}}
317321
>
318-
<NativeStack.Screen name="Onboarding" component={Onboarding} />
322+
<NativeStack.Screen
323+
name="Onboarding"
324+
component={Onboarding}
325+
options={{
326+
headerShown: false,
327+
contentStyle: { backgroundColor: onboardingCanvasColor },
328+
}}
329+
/>
319330
<NativeStack.Screen
320331
name={Routes.ONBOARDING.SOCIAL_LOGIN_SUCCESS_NEW_USER}
321332
component={SocialLoginSuccessNewUser}
@@ -453,29 +464,42 @@ const SimpleWebviewScreen = () => (
453464
</NativeStack.Navigator>
454465
);
455466

456-
const OnboardingRootNav = () => (
457-
<NativeStack.Navigator
458-
initialRouteName={Routes.ONBOARDING.NAV}
459-
screenOptions={{ headerShown: false }}
460-
>
461-
<NativeStack.Screen name="OnboardingNav" component={OnboardingNav} />
462-
<NativeStack.Screen
463-
name={Routes.QR_TAB_SWITCHER}
464-
component={QRTabSwitcherWithMessenger}
465-
options={{ presentation: 'modal' }}
466-
/>
467-
<NativeStack.Screen
468-
name={Routes.SHEET.ADD_DEVICE_VERIFICATION_CODE}
469-
component={VerificationCodeBottomSheet}
470-
options={addDeviceVerificationCodeScreenOptions}
471-
/>
472-
<NativeStack.Screen
473-
name={Routes.WEBVIEW.MAIN}
474-
component={SimpleWebviewScreen}
475-
options={{ presentation: 'modal' }}
476-
/>
477-
</NativeStack.Navigator>
478-
);
467+
const OnboardingRootNav = () => {
468+
const { themeAppearance } = useTheme();
469+
const onboardingCanvasColor =
470+
themeAppearance === 'dark'
471+
? importedColors.gettingStartedTextColor
472+
: importedColors.gettingStartedPageBackgroundColorLightMode;
473+
474+
return (
475+
<NativeStack.Navigator
476+
initialRouteName={Routes.ONBOARDING.NAV}
477+
screenOptions={{
478+
headerShown: false,
479+
// Keep stack chrome cream/purple so Android gesture inset never flashes
480+
// the default white window background behind Onboarding.
481+
contentStyle: { backgroundColor: onboardingCanvasColor },
482+
}}
483+
>
484+
<NativeStack.Screen name="OnboardingNav" component={OnboardingNav} />
485+
<NativeStack.Screen
486+
name={Routes.QR_TAB_SWITCHER}
487+
component={QRTabSwitcherWithMessenger}
488+
options={{ presentation: 'modal' }}
489+
/>
490+
<NativeStack.Screen
491+
name={Routes.SHEET.ADD_DEVICE_VERIFICATION_CODE}
492+
component={VerificationCodeBottomSheet}
493+
options={addDeviceVerificationCodeScreenOptions}
494+
/>
495+
<NativeStack.Screen
496+
name={Routes.WEBVIEW.MAIN}
497+
component={SimpleWebviewScreen}
498+
options={{ presentation: 'modal' }}
499+
/>
500+
</NativeStack.Navigator>
501+
);
502+
};
479503

480504
const VaultRecoveryFlow = () => {
481505
const { colors } = useTheme();
@@ -1140,7 +1164,11 @@ const ModalSwitchAccountType = () => (
11401164
);
11411165

11421166
const AppFlow = () => {
1143-
const { colors } = useTheme();
1167+
const { colors, themeAppearance } = useTheme();
1168+
const onboardingCanvasColor =
1169+
themeAppearance === 'dark'
1170+
? importedColors.gettingStartedTextColor
1171+
: importedColors.gettingStartedPageBackgroundColorLightMode;
11441172

11451173
return (
11461174
<NativeStack.Navigator
@@ -1172,6 +1200,13 @@ const AppFlow = () => {
11721200
<NativeStack.Screen
11731201
name="OnboardingRootNav"
11741202
component={OnboardingRootNav}
1203+
options={{
1204+
// Opaque card: AppFlow defaults to transparentModal, which lets the
1205+
// activity window's white theme color show in the Android gesture
1206+
// inset under the fox ("paper cut").
1207+
presentation: 'card',
1208+
contentStyle: { backgroundColor: onboardingCanvasColor },
1209+
}}
11751210
/>
11761211
<NativeStack.Screen
11771212
name={Routes.ONBOARDING.SUCCESS_FLOW}

app/components/UI/FoxAnimation/FoxAnimation.test.tsx

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React from 'react';
2+
import { Platform } from 'react-native';
23
import { render, act } from '@testing-library/react-native';
3-
import FoxAnimation from './FoxAnimation';
4+
import FoxAnimation, { getSafeBottomPosition } from './FoxAnimation';
45
import Logger from '../../../util/Logger';
56
import Device from '../../../util/device';
67
import {
@@ -16,6 +17,107 @@ jest.mock('../../../util/device');
1617
const mockedLogger = Logger as jest.Mocked<typeof Logger>;
1718
const mockedDevice = Device as jest.Mocked<typeof Device>;
1819

20+
const insets = (bottom: number) => ({
21+
top: 0,
22+
left: 0,
23+
right: 0,
24+
bottom,
25+
});
26+
27+
describe('getSafeBottomPosition', () => {
28+
const originalOS = Platform.OS;
29+
30+
afterEach(() => {
31+
Object.defineProperty(Platform, 'OS', {
32+
configurable: true,
33+
get: () => originalOS,
34+
});
35+
});
36+
37+
const setPlatformOS = (os: typeof Platform.OS) => {
38+
Object.defineProperty(Platform, 'OS', {
39+
configurable: true,
40+
get: () => os,
41+
});
42+
};
43+
44+
it('returns iOS footer offset using bottom inset plus 60', () => {
45+
setPlatformOS('ios');
46+
47+
expect(getSafeBottomPosition(true, insets(50))).toBe(110);
48+
});
49+
50+
it('returns minimum 100 for iOS footer when inset is small', () => {
51+
setPlatformOS('ios');
52+
53+
expect(getSafeBottomPosition(true, insets(0))).toBe(100);
54+
});
55+
56+
it('returns Android footer offset with large inset', () => {
57+
setPlatformOS('android');
58+
59+
expect(getSafeBottomPosition(true, insets(48))).toBe(108);
60+
});
61+
62+
it('returns Android footer offset with small inset', () => {
63+
setPlatformOS('android');
64+
65+
expect(getSafeBottomPosition(true, insets(10))).toBe(100);
66+
});
67+
68+
it('returns 100 for footer on non-iOS non-Android platforms', () => {
69+
setPlatformOS('web');
70+
71+
expect(getSafeBottomPosition(true, insets(0))).toBe(100);
72+
});
73+
74+
it('returns negative iOS offset when home indicator inset is present', () => {
75+
setPlatformOS('ios');
76+
77+
expect(getSafeBottomPosition(false, insets(34))).toBe(-24);
78+
});
79+
80+
it('clamps iOS no-footer offset to -40 for large home indicator', () => {
81+
setPlatformOS('ios');
82+
83+
expect(getSafeBottomPosition(false, insets(80))).toBe(-40);
84+
});
85+
86+
it('returns -20 for iOS with no bottom inset', () => {
87+
setPlatformOS('ios');
88+
89+
expect(getSafeBottomPosition(false, insets(0))).toBe(-20);
90+
});
91+
92+
it('tucks Android full-bleed fox into the gesture inset like iOS', () => {
93+
setPlatformOS('android');
94+
95+
expect(
96+
getSafeBottomPosition(false, insets(48), { fullBleedBottom: true }),
97+
).toBe(-38);
98+
});
99+
100+
it('uses a small negative Android full-bleed offset when inset is missing', () => {
101+
setPlatformOS('android');
102+
103+
expect(
104+
getSafeBottomPosition(false, undefined, { fullBleedBottom: true }),
105+
).toBe(-20);
106+
});
107+
108+
it('returns 0 for Android when parent already applied bottom safe area', () => {
109+
setPlatformOS('android');
110+
111+
expect(getSafeBottomPosition(false, insets(48))).toBe(0);
112+
});
113+
114+
it('returns -20 for no-footer on non-iOS non-Android platforms', () => {
115+
setPlatformOS('web');
116+
117+
expect(getSafeBottomPosition(false, insets(0))).toBe(-20);
118+
});
119+
});
120+
19121
describe('FoxAnimation', () => {
20122
beforeEach(() => {
21123
jest.clearAllMocks();

app/components/UI/FoxAnimation/FoxAnimation.tsx

Lines changed: 55 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -22,68 +22,84 @@ const getFoxAnimationHeight = (hasFooter: boolean) => {
2222
return Device.isMediumDevice() ? 300 : 350;
2323
};
2424

25-
const getSafeBottomPosition = (hasFooter: boolean, insets?: EdgeInsets) => {
25+
export interface FoxSafeBottomOptions {
26+
/**
27+
* Parent paints under the Android system nav / gesture area (e.g. Onboarding
28+
* root canvas with top-only SafeArea). When true, Android uses a negative
29+
* bottom like iOS so the fox sits flush (no cream strip under the art).
30+
* Login must leave this false — its SafeAreaView already applied the inset.
31+
*/
32+
fullBleedBottom?: boolean;
33+
}
34+
35+
export const getSafeBottomPosition = (
36+
hasFooter: boolean,
37+
insets?: EdgeInsets,
38+
options?: FoxSafeBottomOptions,
39+
) => {
2640
const basePadding = insets?.bottom || 0;
2741

28-
// iOS specific
29-
if (Platform.OS === 'ios') {
30-
if (hasFooter) {
31-
// Footer case: position above footer + safe area
42+
if (hasFooter) {
43+
if (Platform.OS === 'ios') {
3244
return Math.max(100, basePadding + 60);
3345
}
46+
if (Platform.OS === 'android') {
47+
return Math.max(100, basePadding + (basePadding > 20 ? 60 : 40));
48+
}
49+
return 100;
50+
}
51+
52+
if (Platform.OS === 'ios') {
3453
if (basePadding > 0) {
35-
// iPhone X+ with home indicator
3654
return Math.max(-40, -(basePadding - 10));
3755
}
3856
return -20;
3957
}
4058

41-
// Android specific
4259
if (Platform.OS === 'android') {
43-
// Samsung and other Android devices with gesture navigation
44-
if (basePadding > 20) {
45-
return hasFooter
46-
? Math.max(100, basePadding + 60)
47-
: Math.max(-20, basePadding);
60+
// Positive insets.bottom lifts the fox and leaves a cream/white strip under
61+
// the graphic on full-bleed onboarding. Tuck like iOS instead.
62+
if (options?.fullBleedBottom) {
63+
if (basePadding > 0) {
64+
return Math.max(-40, -(basePadding - 10));
65+
}
66+
// Edge-to-edge often reports 0 inset; still pull slightly into the gesture area.
67+
return -20;
4868
}
49-
50-
// Standard Android devices
51-
return hasFooter
52-
? Math.max(100, basePadding + 40)
53-
: Math.max(-20, basePadding - 20);
69+
// Login (and other bottom-safe parents): do not double-count the inset.
70+
return 0;
5471
}
5572

56-
// Fallback for other platforms
57-
return hasFooter ? 100 : -20;
73+
return -20;
5874
};
5975

60-
const createStyles = (hasFooter: boolean, insets?: EdgeInsets) =>
61-
StyleSheet.create({
62-
foxAnimationWrapper: {
63-
position: 'absolute',
64-
bottom: getSafeBottomPosition(hasFooter, insets),
65-
left: 0,
66-
right: 0,
67-
height: getFoxAnimationHeight(hasFooter),
68-
alignItems: 'center',
69-
justifyContent: 'center',
70-
pointerEvents: 'none',
71-
},
72-
foxAnimation: {
73-
width: '100%',
74-
height: '100%',
75-
},
76-
});
76+
const styles = StyleSheet.create({
77+
foxAnimationWrapper: {
78+
position: 'absolute',
79+
left: 0,
80+
right: 0,
81+
alignItems: 'center',
82+
justifyContent: 'center',
83+
pointerEvents: 'none',
84+
},
85+
foxAnimation: {
86+
width: '100%',
87+
height: '100%',
88+
},
89+
});
7790

7891
const FoxAnimation = ({
7992
hasFooter,
8093
trigger,
94+
fullBleedBottom = false,
8195
}: {
8296
hasFooter: boolean;
8397
trigger?: 'Loader' | 'Start';
98+
fullBleedBottom?: boolean;
8499
}) => {
85100
const insets = useSafeAreaInsets();
86-
const styles = createStyles(hasFooter, insets);
101+
const bottom = getSafeBottomPosition(hasFooter, insets, { fullBleedBottom });
102+
const height = getFoxAnimationHeight(hasFooter);
87103

88104
const { riveFile } = useRiveFile(FoxAnimationRive);
89105
const { riveViewRef, setHybridRef } = useRive();
@@ -103,15 +119,15 @@ const FoxAnimation = ({
103119
}, [riveViewRef, riveHandlers, trigger]);
104120

105121
return (
106-
<View style={[styles.foxAnimationWrapper]}>
122+
<View style={[styles.foxAnimationWrapper, { bottom, height }]}>
107123
{riveFile && (
108124
<RiveView
109125
hybridRef={setHybridRef}
110126
style={styles.foxAnimation}
111127
file={riveFile}
112128
autoPlay
113129
fit={Fit.Contain}
114-
alignment={Alignment.Center}
130+
alignment={hasFooter ? Alignment.Center : Alignment.BottomCenter}
115131
stateMachineName="FoxRaiseUp"
116132
testID="fox-animation"
117133
onError={(riveError) => {

0 commit comments

Comments
 (0)