Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
adb6275
feat: ota update modal
weitingsun Dec 17, 2025
0ba1915
refactor OTA update modal
weitingsun Dec 18, 2025
a203661
test version 7.62.90
weitingsun Dec 18, 2025
c8f126c
[skip ci] Bump version number to 3310
metamaskbot Dec 18, 2025
b4c1792
revert version and build number
weitingsun Dec 19, 2025
40444aa
change feature flag to useSelector
weitingsun Dec 19, 2025
c17d45a
Merge branch 'main' into wsun/ota-update-modal
weitingsun Dec 19, 2025
a714db7
update build number
weitingsun Dec 19, 2025
e49e344
revert build number
weitingsun Dec 19, 2025
a4c94b2
move OTA update to Nav/Main
weitingsun Dec 19, 2025
d0e4deb
change version
weitingsun Dec 19, 2025
4adbe59
remove dup useMetrics mock
weitingsun Dec 19, 2025
a2efb12
[skip ci] Bump version number to 3315
metamaskbot Dec 19, 2025
977ca4f
debug Android
weitingsun Dec 19, 2025
228b1ea
Merge branch 'wsun/ota-update-modal' of github.com:MetaMask/metamask-…
weitingsun Dec 19, 2025
f97a0f2
[skip ci] Bump version number to 3319
metamaskbot Dec 19, 2025
c1d7041
exit the app for Android users
weitingsun Dec 19, 2025
57b3987
Merge branch 'wsun/ota-update-modal' of github.com:MetaMask/metamask-…
weitingsun Dec 19, 2025
670eb90
[skip ci] Bump version number to 3322
metamaskbot Dec 19, 2025
e9105b3
revert testing code
weitingsun Dec 20, 2025
b39207a
Merge branch 'wsun/ota-update-modal' of github.com:MetaMask/metamask-…
weitingsun Dec 20, 2025
b7c04fc
Merge branch 'main' into wsun/ota-update-modal
weitingsun Dec 22, 2025
78ab060
Merge branch 'main' into wsun/ota-update-modal
weitingsun Jan 5, 2026
26b43ed
Merge branch 'main' into wsun/ota-update-modal
weitingsun Jan 6, 2026
32c36ea
change OTA update modal textcopy on Android
weitingsun Jan 6, 2026
50f7b8f
Merge branch 'main' into wsun/ota-update-modal
weitingsun Jan 6, 2026
880c8e9
fix blank line issue
weitingsun Jan 6, 2026
bedde63
Merge branch 'wsun/ota-update-modal' of github.com:MetaMask/metamask-…
weitingsun Jan 6, 2026
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
32 changes: 4 additions & 28 deletions app/components/Nav/App/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import { KeyringTypes } from '@metamask/keyring-controller';
import { AccountDetailsIds } from '../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors';
import { AvatarAccountType } from '../../../component-library/components/Avatars/Avatar';
import AUTHENTICATION_TYPE from '../../../constants/userProperties';
import { useOTAUpdates } from '../../hooks/useOTAUpdates';

const initialState: DeepPartial<RootState> = {
user: {
Expand Down Expand Up @@ -83,16 +82,6 @@ jest.mock('../../hooks/useMetrics/useMetrics', () => ({
}),
}));

jest.mock('../../hooks/useOTAUpdates', () => ({
useOTAUpdates: jest.fn().mockReturnValue({
isCheckingUpdates: false,
}),
}));

const mockUseOTAUpdates = useOTAUpdates as jest.MockedFunction<
typeof useOTAUpdates
>;

jest.mock(
'../../UI/FoxLoader',
() =>
Expand Down Expand Up @@ -123,6 +112,10 @@ jest.mock('../../../core/OAuthService/OAuthLoginHandlers', () => ({
createLoginHandler: jest.fn(),
}));

jest.mock('../../hooks/useOTAUpdates', () => ({
useOTAUpdates: jest.fn(),
}));

// Mock the navigation hook
const mockNavigate = jest.fn();
const mockReset = jest.fn();
Expand Down Expand Up @@ -269,9 +262,6 @@ describe('App', () => {

beforeEach(() => {
jest.clearAllMocks();
mockUseOTAUpdates.mockReturnValue({
isCheckingUpdates: false,
});
mockNavigate.mockClear();
});

Expand All @@ -284,20 +274,6 @@ describe('App', () => {
jest.useRealTimers();
});

it('renders FoxLoader when OTA update check runs', () => {
mockUseOTAUpdates.mockReturnValue({
isCheckingUpdates: true,
});

const { getByTestId } = renderScreen(
App,
{ name: 'App' },
{ state: initialState },
);

expect(getByTestId(MOCK_FOX_LOADER_ID)).toBeTruthy();
});

it('configures MetaMetrics instance and identifies user on startup', async () => {
renderScreen(App, { name: 'App' }, { state: initialState });
await waitFor(() => {
Expand Down
21 changes: 9 additions & 12 deletions app/components/Nav/App/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
import SelectHardwareWallet from '../../Views/ConnectHardware/SelectHardware';
import { AUTHENTICATION_APP_TRIGGERED_AUTH_NO_CREDENTIALS } from '../../../constants/error';
import { UpdateNeeded } from '../../../components/UI/UpdateNeeded';
import { OTAUpdatesModal } from '../../UI/OTAUpdatesModal';
import NetworkSettings from '../../Views/Settings/NetworksSettings/NetworkSettings';
import ModalMandatory from '../../../component-library/components/Modals/ModalMandatory';
import { RestoreWallet } from '../../Views/RestoreWallet';
Expand Down Expand Up @@ -149,7 +150,6 @@
import useInterval from '../../hooks/useInterval';
import { Duration } from '@metamask/utils';
import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController';
import { useOTAUpdates } from '../../hooks/useOTAUpdates';
import { SmartAccountUpdateModal } from '../../Views/confirmations/components/smart-account-update-modal';
import { PayWithModal } from '../../Views/confirmations/components/modals/pay-with-modal/pay-with-modal';
import { useMetrics } from '../../hooks/useMetrics';
Expand All @@ -161,6 +161,7 @@
import { trackVaultCorruption } from '../../../util/analytics/vaultCorruptionTracking';
import SocialLoginIosUser from '../../Views/SocialLoginIosUser';
import AUTHENTICATION_TYPE from '../../../constants/userProperties';
import { useOTAUpdates } from '../../hooks/useOTAUpdates';

const clearStackNavigatorOptions = {
headerShown: false,
Expand Down Expand Up @@ -516,6 +517,10 @@
<Stack.Screen name={'AssetOptions'} component={AssetOptions} />
<Stack.Screen name={'NftOptions'} component={NftOptions} />
<Stack.Screen name={Routes.MODAL.UPDATE_NEEDED} component={UpdateNeeded} />
<Stack.Screen
name={Routes.MODAL.OTA_UPDATES_MODAL}
component={OTAUpdatesModal}
/>
{
<Stack.Screen
name={Routes.SHEET.SELECT_SRP}
Expand Down Expand Up @@ -1088,7 +1093,7 @@
);
};

const AppContent: React.FC = () => {
const App: React.FC = () => {
const navigation = useNavigation();
const routes = useNavigationState((state) => state.routes);
const { toastRef } = useContext(ToastContext);
Expand All @@ -1098,6 +1103,8 @@
selectSeedlessOnboardingLoginFlow,
);

useOTAUpdates();

if (isFirstRender.current) {
trace({
name: TraceName.NavInit,
Expand Down Expand Up @@ -1268,7 +1275,7 @@
Logger.error(error, 'Error starting app');
});
// existingUser is not present in the dependency array because it is not needed to re-run the effect when it changes and it will cause a bug.
// eslint-disable-next-line react-hooks/exhaustive-deps

Check warning on line 1278 in app/components/Nav/App/App.tsx

View workflow job for this annotation

GitHub Actions / scripts (lint)

React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior
}, []);

return (
Expand All @@ -1280,14 +1287,4 @@
);
};

const App: React.FC = () => {
const { isCheckingUpdates } = useOTAUpdates();

if (isCheckingUpdates) {
return <FoxLoader />;
}

return <AppContent />;
};

export default App;
161 changes: 161 additions & 0 deletions app/components/UI/OTAUpdatesModal/OTAUpdatesModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import React from 'react';
import { render, fireEvent, waitFor } from '@testing-library/react-native';
import { Platform } from 'react-native';
import { reloadAsync } from 'expo-updates';
import OTAUpdatesModal from './OTAUpdatesModal';
import Logger from '../../../util/Logger';
import { MetaMetricsEvents } from '../../../core/Analytics';

jest.mock(
'../../../component-library/components/BottomSheets/BottomSheet',
() => {
const { View } = jest.requireActual('react-native');
const { forwardRef, useImperativeHandle } = jest.requireActual('react');

const MockBottomSheet = forwardRef(
(props: { children: React.ReactNode }, ref: React.Ref<unknown>) => {
useImperativeHandle(ref, () => ({
onOpenBottomSheet: jest.fn(),
onCloseBottomSheet: jest.fn((callback?: () => void) => {
if (callback) callback();
}),
}));

return (
<View testID="bottom-sheet" {...props}>
{props.children}
</View>
);
},
);

return {
__esModule: true,
default: MockBottomSheet,
};
},
);

jest.mock('expo-updates', () => ({
reloadAsync: jest.fn(),
}));

jest.mock('../../../util/metrics', () => ({
__esModule: true,
default: jest.fn().mockReturnValue({}),
}));

jest.mock('../../../util/Logger', () => ({
log: jest.fn(),
error: jest.fn(),
}));

jest.mock(
'../../../component-library/components/HeaderBase',
() =>
function HeaderBaseMock({ children }: { children: React.ReactNode }) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{children}</>;
},
);

const mockReloadAsync = reloadAsync as jest.MockedFunction<typeof reloadAsync>;
const mockLoggerError = Logger.error as jest.MockedFunction<
typeof Logger.error
>;

interface MockEventBuilder {
addProperties: jest.Mock;
build: jest.Mock;
}

const mockCreateEventBuilder = jest.fn((event: string): MockEventBuilder => {
const builder: MockEventBuilder = {
addProperties: jest.fn(),
build: jest.fn(),
};

builder.addProperties.mockReturnValue(builder);
builder.build.mockReturnValue({ event });

return builder;
});

const mockTrackEvent = jest.fn();

jest.mock('../../hooks/useMetrics', () => ({
useMetrics: () => ({
trackEvent: mockTrackEvent,
createEventBuilder: mockCreateEventBuilder,
}),
}));

describe('OTAUpdatesModal', () => {
beforeEach(() => {
jest.clearAllMocks();
(Platform as unknown as { OS: string }).OS = 'ios';
});

it('tracks view event on mount', () => {
render(<OTAUpdatesModal />);

expect(mockTrackEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: MetaMetricsEvents.OTA_UPDATES_MODAL_VIEWED,
}),
);
});

it('tracks primary action when primary button is pressed', async () => {
const { getByText } = render(<OTAUpdatesModal />);

fireEvent.press(getByText('Reload'));

await waitFor(() => {
expect(mockTrackEvent).toHaveBeenCalledWith(
expect.objectContaining({
event: MetaMetricsEvents.OTA_UPDATES_MODAL_PRIMARY_ACTION_CLICKED,
}),
);
});
});

it('reloads app when reload button is pressed on iOS', async () => {
const { getByText } = render(<OTAUpdatesModal />);

fireEvent.press(getByText('Reload'));

await waitFor(() => {
expect(mockReloadAsync).toHaveBeenCalledTimes(1);
});
});

it('does not reload app when reload button is pressed on Android', async () => {
(Platform as unknown as { OS: string }).OS = 'android';

const { getByText } = render(<OTAUpdatesModal />);

fireEvent.press(getByText('Got it'));

await waitFor(() => {
expect(mockReloadAsync).not.toHaveBeenCalled();
});
});

it('logs error when reloadAsync throws', async () => {
const reloadError = new Error('Reload failed');

mockReloadAsync.mockRejectedValueOnce(reloadError);

const { getByText } = render(<OTAUpdatesModal />);

fireEvent.press(getByText('Reload'));

await waitFor(() => {
expect(mockLoggerError).toHaveBeenCalledWith(
reloadError,
'OTA Updates: Error reloading app after modal reload pressed',
);
});
});
});
Loading
Loading