Skip to content
Merged
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
18 changes: 18 additions & 0 deletions .github/workflows/ios-prebuild-external-xcframeworks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ jobs:
manifest="apps/bare-expo/package.json"
changed=()

# A React Native bump changes the <rnVersion>/<hermesVersion> segments of
# every external artifact path, so the whole published set must be rebuilt.
# Read the version from `dependencies` only, mirroring the producer's source
# of truth in tools/src/prebuilds/Utils.ts (getVersionsInfoAsync).
# `|| true` keeps a base commit without the manifest falling through to
# build_all; without it `pipefail` aborts the step with git's status.
before_rn="$(git show "$base:$manifest" 2>/dev/null \
| jq -r '.dependencies["react-native"] // empty' || true)"
after_rn="$(jq -r '.dependencies["react-native"] // empty' "$manifest")"

if [ -z "$after_rn" ]; then
build_all "Could not read the react-native version from $manifest; building all external packages."
fi

if [ "$before_rn" != "$after_rn" ]; then
build_all "React Native changed (${before_rn:-unknown} -> $after_rn); every external artifact path is invalidated. Building all external packages."
fi

# Derive the package -> patch-file map from pnpm's own source of truth
# (the `patchedDependencies` block in pnpm-workspace.yaml) so adding a
# patched package needs no edits here. Keys may carry an `@version`
Expand Down
22 changes: 7 additions & 15 deletions apps/notification-tester/src/app/index.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,23 @@
import { getPermissionsAsync } from 'expo-notifications';
import { useRouter } from 'expo-router';
import { Alert, Button } from 'react-native';
import { Button, Text } from 'react-native';

import { Notifier } from '../Notifier';
import { ScrollView } from '../misc/Themed';
import { setAppNotificationHandler } from '../registerTaskAsync';

export default function IndexPage() {
const router = useRouter();
return (
<ScrollView contentContainerStyle={{ rowGap: 10, padding: 10 }}>
<Button title="Run on-device tests" onPress={() => router.push('/run')} />
<Text>Runs the tests before this app sets a notification handler.</Text>
<Button
title="Go to NCL NotificationScreen"
onPress={() => router.push('/ncl-notification-screen')}
/>
<Button title="Go to playground" onPress={() => router.push('/playground')} />
<Button title="See Expo ui" onPress={() => router.push('/expo-ui')} />
<Button title="Go to test scenarios" onPress={() => router.push('/scenarios')} />
<Button
title="Get Notification permissions"
title="Open the tester app"
onPress={() => {
getPermissionsAsync()
.then((permissions) => Alert.alert(JSON.stringify(permissions, null, 2)))
.catch((error) => console.error(error));
setAppNotificationHandler();
router.push('/tester');
}}
/>
<Notifier />
<Text>Sets the notification handler of this app, then opens it.</Text>
</ScrollView>
);
}
31 changes: 31 additions & 0 deletions apps/notification-tester/src/app/tester.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { getPermissionsAsync } from 'expo-notifications';
import { useRouter } from 'expo-router';
import { Alert, Button } from 'react-native';

import { Notifier } from '../Notifier';
import { ScrollView } from '../misc/Themed';

export default function TesterPage() {
const router = useRouter();
return (
<ScrollView contentContainerStyle={{ rowGap: 10, padding: 10 }}>
<Button title="Run on-device tests" onPress={() => router.push('/run')} />
<Button
title="Go to NCL NotificationScreen"
onPress={() => router.push('/ncl-notification-screen')}
/>
<Button title="Go to playground" onPress={() => router.push('/playground')} />
<Button title="See Expo ui" onPress={() => router.push('/expo-ui')} />
<Button title="Go to test scenarios" onPress={() => router.push('/scenarios')} />
<Button
title="Get Notification permissions"
onPress={() => {
getPermissionsAsync()
.then((permissions) => Alert.alert(JSON.stringify(permissions, null, 2)))
.catch((error) => console.error(error));
}}
/>
<Notifier />
</ScrollView>
);
}
8 changes: 7 additions & 1 deletion apps/notification-tester/src/registerTaskAsync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,8 +133,14 @@ export const registerTask = () => {

// then register the task
Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK).catch(console.error);
};

// set the notification handler
/**
* Installs the notification handler of this app. The on-device tests cover the handler that
* `expo-notifications` installs by itself, so the crossroad at the app start calls this only on
* the way into the app, and never on the way into the tests.
*/
export const setAppNotificationHandler = () => {
setNotificationHandler({
handleNotification: async (notification) => {
const categoryIdentifier = notification.request.content.categoryIdentifier;
Expand Down
91 changes: 91 additions & 0 deletions apps/test-suite/tests/Notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,98 @@ export async function test(t: JasmineInterface) {
const describeWithPermissions = shouldSkipTestsRequiringPermissions ? t.xdescribe : t.describe;
const onlyInteractiveDescribe = isInteractive() ? t.describe : t.xdescribe;

const describeForegroundBehavior = ['ios', 'android'].includes(Platform.OS)
? describeWithPermissions
: t.xdescribe;

t.describe('Notifications', () => {
// Keep this block first. Its first spec covers the behavior that applies until something
// calls `setNotificationHandler`, and nothing puts the module back into that state.
describeForegroundBehavior('foreground notification behavior', () => {
// Every spec below asserts over the notifications that the system currently shows, so a
// notification left behind by an earlier run would answer for the one the spec schedules.
t.beforeEach(async () => {
await Notifications.dismissAllNotificationsAsync();
});

const presentedIdentifiers = async () =>
(await Notifications.getPresentedNotificationsAsync()).map(
(notification) => notification.request.identifier
);

// The notification center lists a notification that arrived while the app is in the
// foreground even before anything decided how to present it, and drops it again when the
// answer asks for nothing. So the list says nothing until the answer is in, and every spec
// below has to read it only after the decision settled. The native code waits 3 seconds for
// a handler, which is the longest that can take.
const settleDecision = () => waitFor(5000);

t.it(
'shows the local notification when the app sets no handler',
async () => {
const identifier = 'test-default-foreground-behavior';
await Notifications.scheduleNotificationAsync({
identifier,
content: { title: 'Default behavior', body: 'Shown without a notification handler' },
trigger: null,
});

// Without a built-in handler nothing would answer the notification center, which answers
// for itself with "present nothing" and would drop the notification from the list. Reading
// the list after the decision settled is therefore what covers the default.
await settleDecision();
t.expect(await presentedIdentifiers()).toContain(identifier);
await Notifications.dismissNotificationAsync(identifier);
},
15000
);

t.it(
'when the handler of the app does not respond in time, we show the notification once the handler times out',
async () => {
Notifications.setNotificationHandler({
handleNotification: async () => {
await waitFor(4000);
return behaviorEnableAll;
},
});

const identifier = 'test-slow-handler';
t.expect(await presentedIdentifiers()).not.toContain(identifier);
await Notifications.scheduleNotificationAsync({
identifier,
content: { title: 'Slow handler', body: 'Shown after the handler times out' },
trigger: null,
});
// The handler answers after 4 seconds, so the 3 second timeout of the native
// code is what presents this notification. Without that fallback the notification
// center would never get an answer and would drop the notification.
await settleDecision();
t.expect(await presentedIdentifiers()).toContain(identifier);
await Notifications.dismissNotificationAsync(identifier);
},
20000
);

t.it(
'when the app removes the handler, we do not show the notification',
async () => {
Notifications.setNotificationHandler(null);

const identifier = 'test-removed-handler';
await Notifications.scheduleNotificationAsync({
identifier,
content: { title: 'No handler', body: 'Not shown by expo-notifications' },
trigger: null,
});

await settleDecision();
t.expect(await presentedIdentifiers()).not.toContain(identifier);
},
15000
);
});

t.describe('getDevicePushTokenAsync', () => {
t.it('resolves with a token equal to the one from addPushTokenListener()', async () => {
// Held in an object so the assignment from the listener is visible to
Expand Down
16 changes: 15 additions & 1 deletion docs/pages/push-notifications/receiving-notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,11 @@ For more information on these objects, see [`Notification`](/versions/latest/sdk

## Foreground notification behavior

To handle the behavior when notifications are received when your app is **foregrounded**, use [`Notifications.setNotificationHandler`](/versions/latest/sdk/notifications/#present-incoming-notifications-when-the-app-is) with the `handleNotification()` callback to set the following options:
In SDK 57 and earlier, a foreground notification is not shown at all by default. To show it, you have to set a handler that asks for it using [`Notifications.setNotificationHandler`](/versions/latest/sdk/notifications/#present-incoming-notifications-when-the-app-is). A handler that does not respond within 3 seconds drops the notification.

In SDK 58 and later, a notification that arrives while your app is **foregrounded** is shown by default. It plays a sound, shows a banner, appears in the notification list, and sets the app badge. A notification whose handler does not respond within 3 seconds is shown the same way.

To change the behavior, use [`Notifications.setNotificationHandler`](/versions/latest/sdk/notifications/#present-incoming-notifications-when-the-app-is) with the `handleNotification()` callback to set the following options:

- `shouldPlaySound`
- `shouldSetBadge`
Expand All @@ -239,6 +243,16 @@ Notifications.setNotificationHandler({
});
```

### Disable the notification handler

In SDK 58 and later, to stop `expo-notifications` from deciding whether an incoming notification shows, pass `null`:

```jsx
Notifications.setNotificationHandler(null);
```

On Android, the notification is then not shown while your app is in the foreground. On iOS, the decision goes to the `UNUserNotificationCenterDelegate` that another library sets. If no library sets one, the notification is not shown.

## Closed notification behavior

On Android, users can set certain OS-level settings, usually revolving around performance and battery optimization, that can prevent notifications from being delivered when the app is closed. For example, one such setting is the **Deep Clear** option on OnePlus devices using Android 9 and lower versions.
12 changes: 0 additions & 12 deletions docs/pages/versions/unversioned/sdk/notifications.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -205,18 +205,6 @@ async function registerForPushNotificationsAsync() {
```ts
import * as Notifications from 'expo-notifications';

// First, set the handler that will cause the notification
// to show the alert
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});

// Second, call scheduleNotificationAsync()
Notifications.scheduleNotificationAsync({
content: {
title: 'Look at that notification',
Expand Down

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions docs/ui/components/ExpoSkillsTable/data/expo-skills.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
"source": {
"repo": "expo/skills",
"url": "https://api.github.com/repos/expo/skills/contents/plugins/expo/skills",
"fetchedAt": "2026-08-13T18:42:57.000Z"
"fetchedAt": "2026-08-19T08:11:32.877Z"
},
"totalSkills": 22,
"totalSkills": 23,
"skills": [
{
"name": "eas-app-stores",
Expand Down Expand Up @@ -42,6 +42,12 @@
"description": "Helps understand and write EAS workflow YAML files for Expo projects. Use this skill when the user asks about CI/CD or workflows in an Expo or EAS context, mentions .eas/workflows/, or wants help with EAS build pipelines or deployment automation.",
"githubUrl": "https://github.com/expo/skills/blob/main/plugins/expo/skills/eas-workflows/SKILL.md"
},
{
"name": "expo-animation",
"category": "framework",
"description": "Build animations in React Native and Expo, making the decisions in the order that determines whether they feel right — should it animate, which thread it runs on, which properties, spring or timing, how the gesture hands off, how it degrades. Writes the implementation with Reanimated, Gesture Handler, Expo Router and expo-haptics. Use when animating anything in an Expo app, adding gestures, sheets, screen transitions, press feedback or haptics, or fixing motion that stutters on device. For web animation use `animate`.",
"githubUrl": "https://github.com/expo/skills/blob/main/plugins/expo/skills/expo-animation/SKILL.md"
},
{
"name": "expo-app-clip",
"category": "framework",
Expand Down
1 change: 1 addition & 0 deletions packages/@expo/config-plugins/src/ios/IosConfig.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,5 @@ export type ExpoPlist = {
EXUpdatesCodeSigningMetadata?: Record<string, string>;
EXUpdatesDisableAntiBrickingMeasures?: boolean;
EXUpdatesEnableBsdiffPatchSupport?: boolean;
EXUpdatesExcludeFromBackup?: boolean;
};
9 changes: 9 additions & 0 deletions packages/@expo/config-plugins/src/ios/Updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getUpdatesEnabled,
getUpdatesTimeout,
getUpdatesBsdiffPatchSupportEnabled,
getUpdatesExcludeFromBackup,
getUpdatesUseEmbeddedUpdate,
getUpdateUrl,
} from '../utils/Updates';
Expand All @@ -34,6 +35,7 @@ export enum Config {
CODE_SIGNING_METADATA = 'EXUpdatesCodeSigningMetadata',
DISABLE_ANTI_BRICKING_MEASURES = 'EXUpdatesDisableAntiBrickingMeasures',
ENABLE_BSDIFF_PATCH_SUPPORT = 'EXUpdatesEnableBsdiffPatchSupport',
EXCLUDE_FROM_BACKUP = 'EXUpdatesExcludeFromBackup',
}

// when making changes to this config plugin, ensure the same changes are also made in eas-cli and build-tools
Expand Down Expand Up @@ -140,6 +142,13 @@ export async function setUpdatesConfigAsync(
delete newExpoPlist[Config.DISABLE_ANTI_BRICKING_MEASURES];
}

const excludeFromBackup = getUpdatesExcludeFromBackup(config);
if (excludeFromBackup) {
newExpoPlist[Config.EXCLUDE_FROM_BACKUP] = true;
} else {
delete newExpoPlist[Config.EXCLUDE_FROM_BACKUP];
}

newExpoPlist[Config.ENABLE_BSDIFF_PATCH_SUPPORT] = getUpdatesBsdiffPatchSupportEnabled(config);

return await setVersionsConfigAsync(projectRoot, config, newExpoPlist);
Expand Down
26 changes: 26 additions & 0 deletions packages/@expo/config-plugins/src/ios/__tests__/Updates-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,30 @@ describe('iOS Updates config', () => {
EXUpdatesEnableBsdiffPatchSupport: true,
});
});

it('writes EXUpdatesExcludeFromBackup only when updates.excludeFromBackup is true', async () => {
const enabled = await Updates.setUpdatesConfigAsync(
'/app',
{
runtimeVersion: '1.0.0',
slug: 'my-app',
updates: { url: 'https://u.expo.dev/x', excludeFromBackup: true },
},
{} as any,
'0.11.0'
);
expect(enabled).toMatchObject({ EXUpdatesExcludeFromBackup: true });

const omitted = await Updates.setUpdatesConfigAsync(
'/app',
{
runtimeVersion: '1.0.0',
slug: 'my-app',
updates: { url: 'https://u.expo.dev/x' },
},
{} as any,
'0.11.0'
);
expect(omitted).not.toHaveProperty('EXUpdatesExcludeFromBackup');
});
});
4 changes: 4 additions & 0 deletions packages/@expo/config-plugins/src/utils/Updates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,7 @@ export function getDisableAntiBrickingMeasures(
): boolean | undefined {
return config.updates?.disableAntiBrickingMeasures;
}

export function getUpdatesExcludeFromBackup(config: Pick<ExpoConfigUpdates, 'updates'>): boolean {
return config.updates?.excludeFromBackup ?? false;
}
15 changes: 15 additions & 0 deletions packages/@expo/config-plugins/src/utils/__tests__/Updates-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
getUpdatesRequestHeadersStringified,
getUpdatesEnabled,
getUpdatesTimeout,
getUpdatesExcludeFromBackup,
getUpdatesUseEmbeddedUpdate,
getUpdateUrl,
FINGERPRINT_RUNTIME_VERSION_SENTINEL,
Expand Down Expand Up @@ -231,6 +232,20 @@ describe(getUpdatesUseEmbeddedUpdate, () => {
});
});

describe(getUpdatesExcludeFromBackup, () => {
it('returns true if updates.excludeFromBackup is true', () => {
expect(getUpdatesExcludeFromBackup({ updates: { excludeFromBackup: true } })).toBe(true);
});

it('returns false if updates.excludeFromBackup is false', () => {
expect(getUpdatesExcludeFromBackup({ updates: { excludeFromBackup: false } })).toBe(false);
});

it('returns false if updates.excludeFromBackup is undefined', () => {
expect(getUpdatesExcludeFromBackup({ updates: {} })).toBe(false);
});
});

describe(getRuntimeVersionAsync, () => {
it('works if the top level runtimeVersion is a string', async () => {
const runtimeVersion = '42';
Expand Down
Loading
Loading