diff --git a/.github/workflows/swift-format.yml b/.github/workflows/swift-format.yml index 26f749fdc030b6..5731cd7bbab42d 100644 --- a/.github/workflows/swift-format.yml +++ b/.github/workflows/swift-format.yml @@ -10,6 +10,7 @@ on: - scripts/swift-format.sh - packages/expo-age-range/**/*.swift - packages/expo-app-integrity/**/*.swift + - packages/expo-app-intents/**/*.swift - packages/expo-app-metrics/**/*.swift - packages/expo-application/**/*.swift - packages/expo-asset/**/*.swift @@ -40,6 +41,7 @@ on: - scripts/swift-format.sh - packages/expo-age-range/**/*.swift - packages/expo-app-integrity/**/*.swift + - packages/expo-app-intents/**/*.swift - packages/expo-app-metrics/**/*.swift - packages/expo-application/**/*.swift - packages/expo-asset/**/*.swift @@ -123,6 +125,7 @@ jobs: PACKAGES: >- packages/expo-age-range packages/expo-app-integrity + packages/expo-app-intents packages/expo-app-metrics packages/expo-application packages/expo-asset diff --git a/apps/bare-expo/ios/Podfile.lock b/apps/bare-expo/ios/Podfile.lock index 9b072f0823d5d2..51e971d9f26af5 100644 --- a/apps/bare-expo/ios/Podfile.lock +++ b/apps/bare-expo/ios/Podfile.lock @@ -264,6 +264,11 @@ PODS: - ExpoModulesCore - ExpoAppIntegrity (57.0.1): - ExpoModulesCore + - ExpoAppIntents (0.1.0): + - ExpoModulesCore + - ExpoAppIntents/Tests (0.1.0): + - ExpoModulesCore + - ExpoModulesTestCore - ExpoAppleAuthentication (57.0.1): - ExpoModulesCore - ExpoAppMetrics (57.0.7): @@ -3353,6 +3358,8 @@ DEPENDENCIES: - Expo/Tests (from `../../../packages/expo`) - ExpoAgeRange (from `../../../packages/expo-age-range/ios`) - ExpoAppIntegrity (from `../../../packages/expo-app-integrity/ios`) + - ExpoAppIntents (from `../../../packages/expo-app-intents/ios`) + - ExpoAppIntents/Tests (from `../../../packages/expo-app-intents/ios`) - ExpoAppleAuthentication (from `../../../packages/expo-apple-authentication/ios`) - ExpoAppMetrics (from `../../../packages/expo-app-metrics/ios`) - ExpoAppMetrics/Tests (from `../../../packages/expo-app-metrics/ios`) @@ -3603,6 +3610,9 @@ EXTERNAL SOURCES: ExpoAppIntegrity: inhibit_warnings: false :path: "../../../packages/expo-app-integrity/ios" + ExpoAppIntents: + inhibit_warnings: false + :path: "../../../packages/expo-app-intents/ios" ExpoAppleAuthentication: inhibit_warnings: false :path: "../../../packages/expo-apple-authentication/ios" @@ -4038,6 +4048,7 @@ SPEC CHECKSUMS: expo-dev-menu-interface: 7a59e803916fbfd1b96c75ff91ea8dddac7e722f ExpoAgeRange: f30bef82f1a99c097e28f50ed41ca04d5e1cab1c ExpoAppIntegrity: c4f6d2065674ec9744881f8c04d52ade9b6cacd8 + ExpoAppIntents: e4702a43af248c7f72ffe4855635f8d0319d330a ExpoAppleAuthentication: 97d113f0f2680067e6e76d3c9249e7e1fc026b42 ExpoAppMetrics: edb2e75dba50059ba781c56b8ff0824aaeb9e74b ExpoAsset: a76534cf7b762978861dd31708308496c676166f @@ -4113,7 +4124,7 @@ SPEC CHECKSUMS: EXUpdates: e8c071fa483afb3d9d1de09e8f4f9427278af0a1 EXUpdatesInterface: 92d2aa5194b6fb93ca8ab749db75665cb28b2e60 FBLazyVector: b3e7ad108f0d882e30445c5527d774e3fd432f3d - hermes-engine: dd9a9191125ffe9f57c224173db85afb0210cd23 + hermes-engine: 4c998771d5218e20701b63d8358199a520a54447 JestMockSchema: 96b4cfec246644f6323d1c30693b6a02044be1e6 libavif: 5f8e715bea24debec477006f21ef9e95432e254d libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f diff --git a/apps/bare-expo/package.json b/apps/bare-expo/package.json index 072a9d0561f0df..b8275ffdd0ebef 100644 --- a/apps/bare-expo/package.json +++ b/apps/bare-expo/package.json @@ -33,6 +33,7 @@ "@shopify/flash-list": "2.0.2", "@shopify/react-native-skia": "2.6.2", "expo": "workspace:*", + "expo-app-intents": "workspace:*", "expo-app-metrics": "workspace:*", "expo-background-fetch": "workspace:*", "expo-brownfield": "workspace:*", diff --git a/apps/test-suite/tests/JSDestructuring.js b/apps/test-suite/tests/JSDestructuring.js index 414ad8208f86c2..a640c066f181e6 100644 --- a/apps/test-suite/tests/JSDestructuring.js +++ b/apps/test-suite/tests/JSDestructuring.js @@ -8,6 +8,9 @@ export const name = 'JS Destructuring'; +// Keep object rest in an exported declaration to cover the export-specific transform path. +export const { exportedValue, ...exportedRest } = { exportedValue: 1, exportedOther: 2 }; + export function test({ describe, it, expect }) { describe('JS Destructuring', () => { describe('object patterns', () => { @@ -79,6 +82,11 @@ export function test({ describe, it, expect }) { expect(rest).toEqual({}); }); + it('collects a rest-only object pattern', () => { + const { ...copy } = { a: 1, b: 2 }; + expect(copy).toEqual({ a: 1, b: 2 }); + }); + it('handles nested object patterns', () => { const { a: { b }, @@ -93,6 +101,33 @@ export function test({ describe, it, expect }) { expect(b).toBe(99); }); + it('collects nested object rest at multiple levels', () => { + const { + outer: { + inner: { selected, ...innerRest }, + ...outerRest + }, + ...rootRest + } = { + outer: { inner: { selected: 1, kept: 2 }, sibling: 3 }, + root: 4, + }; + expect(selected).toBe(1); + expect(innerRest).toEqual({ kept: 2 }); + expect(outerRest).toEqual({ sibling: 3 }); + expect(rootRest).toEqual({ root: 4 }); + }); + + it('collects object rest behind a computed nested object reference', () => { + let key = 0; + const { + [key++]: { selected, ...rest }, + } = { 0: { selected: 1, kept: 2 } }; + expect(key).toBe(1); + expect(selected).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + it('handles shorthand for prototype properties', () => { const { toString } = {}; expect(typeof toString).toBe('function'); @@ -143,6 +178,76 @@ export function test({ describe, it, expect }) { expect(rest).toEqual({}); }); + // From: transform-object-rest-spread/object-rest/remove-unused-computed-key-loose/exec.js + // Regression test for https://github.com/babel/babel/pull/18197 + it('object rest preserves an unused computed-key exclusion', () => { + function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + + expect(omit({ a: 1, b: 2 }, { key: 'a' })).toEqual({ b: 2 }); + }); + + it('object rest excludes unused computed element and call keys', () => { + const spec = { keys: ['b'] }; + const getKey = () => 'c'; + const { + [spec.keys[0]]: _element, + [getKey()]: _call, + ...rest + } = { + b: 2, + c: 3, + keep: 4, + }; + expect(rest).toEqual({ keep: 4 }); + }); + + it('object rest excludes computed symbol keys and retains other symbols', () => { + const excluded = Symbol('excluded'); + const retained = Symbol('retained'); + const { [excluded]: _excluded, ...rest } = { [excluded]: 1, [retained]: 2, kept: 3 }; + expect(rest[excluded]).toBeUndefined(); + expect(rest[retained]).toBe(2); + expect(rest.kept).toBe(3); + }); + + it('object rest coerces non-string computed exclusion keys', () => { + const keys = [null, undefined, true, false]; + const source = { null: 1, undefined: 2, true: 3, false: 4, kept: 5 }; + const { + [keys[0]]: nullValue, + [keys[1]]: undefinedValue, + [keys[2]]: trueValue, + [keys[3]]: falseValue, + ...rest + } = source; + expect([nullValue, undefinedValue, trueValue, falseValue]).toEqual([1, 2, 3, 4]); + expect(rest).toEqual({ kept: 5 }); + }); + + it('object rest excludes template-literal computed keys', () => { + const prefix = 'user'; + const { [`${prefix}_name`]: name, ...rest } = { user_name: 'Ada', role: 'admin' }; + expect(name).toBe('Ada'); + expect(rest).toEqual({ role: 'admin' }); + }); + + it('evaluates an excluded getter even when its binding is unused', () => { + let called = 0; + const source = { + get excluded() { + called++; + return 1; + }, + kept: 2, + }; + const { excluded: unused, ...rest } = source; + expect(called).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + // From: destructuring/object-rest-impure-computed-keys/exec.js it('object rest with impure computed keys', () => { var key, x, y, z; @@ -187,6 +292,95 @@ export function test({ describe, it, expect }) { var {} = null; }).toThrow(); }); + + it('rest-only object pattern throws on null and undefined', () => { + expect(() => { + const { ...rest } = null; + }).toThrow(); + expect(() => { + const { ...rest } = undefined; + }).toThrow(); + }); + }); + + describe('object spread', () => { + it('copies properties and applies sources from left to right', () => { + const result = { + first: 1, + shared: 'initial', + ...{ second: 2, shared: 'middle' }, + shared: 'last', + }; + expect(result).toEqual({ first: 1, second: 2, shared: 'last' }); + }); + + it('combines multiple spread sources', () => { + const result = { ...{ a: 1 }, b: 2, ...{ c: 3 }, ...{ d: 4 } }; + expect(result).toEqual({ a: 1, b: 2, c: 3, d: 4 }); + }); + + it('transforms object spread in assignment and nested expression contexts', () => { + let assigned; + assigned = { before: 1, ...{ middle: 2 }, after: 3 }; + const nested = { value: { ...assigned, final: 4 } }; + expect(assigned).toEqual({ before: 1, middle: 2, after: 3 }); + expect(nested).toEqual({ value: { before: 1, middle: 2, after: 3, final: 4 } }); + }); + + it('evaluates spread source expressions once from left to right', () => { + const calls = []; + const source = (name, value) => { + calls.push(name); + return value; + }; + const result = { ...source('first', { a: 1 }), ...source('second', { b: 2 }) }; + expect(result).toEqual({ a: 1, b: 2 }); + expect(calls).toEqual(['first', 'second']); + }); + + it('ignores null and undefined spread sources', () => { + expect({ before: 1, ...null, ...undefined, after: 2 }).toEqual({ before: 1, after: 2 }); + }); + + it('copies own enumerable properties but not inherited or non-enumerable properties', () => { + const source = Object.create({ inherited: 1 }); + source.enumerable = 2; + Object.defineProperty(source, 'hidden', { enumerable: false, value: 3 }); + const result = { ...source }; + expect(result).toEqual({ enumerable: 2 }); + expect(result.inherited).toBeUndefined(); + expect(result.hidden).toBeUndefined(); + }); + + it('copies enumerable symbol properties', () => { + const symbol = Symbol('spread'); + const result = { ...{ visible: 1, [symbol]: 2 } }; + expect(result.visible).toBe(1); + expect(result[symbol]).toBe(2); + }); + + it('evaluates spread getters once and in source order', () => { + const order = []; + const first = { + get a() { + order.push('first'); + return 1; + }, + }; + const second = { + get b() { + order.push('second'); + return 2; + }, + }; + expect({ ...first, ...second }).toEqual({ a: 1, b: 2 }); + expect(order).toEqual(['first', 'second']); + }); + + it('spreads enumerable properties from primitive values', () => { + expect({ ...'abc' }).toEqual({ 0: 'a', 1: 'b', 2: 'c' }); + expect({ ...42, ...true }).toEqual({}); + }); }); describe('array patterns', () => { @@ -439,6 +633,22 @@ export function test({ describe, it, expect }) { expect(tail).toEqual([2, 3]); expect(meta).toEqual({ type: 'test', version: 2 }); }); + + it('object rest nested inside array declaration and assignment patterns', () => { + const [first, { selected, ...declaredRest }] = [1, { selected: 2, kept: 3 }]; + let assignedFirst, assignedValue, assignedRest; + [assignedFirst, { selected: assignedValue, ...assignedRest }] = [ + 4, + { selected: 5, kept: 6 }, + ]; + expect([first, selected, declaredRest]).toEqual([1, 2, { kept: 3 }]); + expect([assignedFirst, assignedValue, assignedRest]).toEqual([4, 5, { kept: 6 }]); + }); + + it('supports object rest nested in an array rest target', () => { + const [...{ ...properties }] = ['a', 'b']; + expect(properties).toEqual({ 0: 'a', 1: 'b' }); + }); }); describe('function parameters', () => { @@ -507,6 +717,25 @@ export function test({ describe, it, expect }) { expect(f({ x: 10 })).toBe(12); }); + it('makes object rest available to a later parameter default', () => { + function f({ selected, ...rest }, value = rest.kept) { + return [selected, rest, value]; + } + expect(f({ selected: 1, kept: 2 })).toEqual([1, { kept: 2 }, 2]); + expect(f({ selected: 1, kept: 2 }, 3)).toEqual([1, { kept: 2 }, 3]); + }); + + it('supports nested object rest in function parameters', () => { + function f({ outer: { selected, ...innerRest }, ...outerRest } = { outer: {} }) { + return [selected, innerRest, outerRest]; + } + expect(f({ outer: { selected: 1, kept: 2 }, root: 3 })).toEqual([ + 1, + { kept: 2 }, + { root: 3 }, + ]); + }); + // From: destructuring/default-precedence/exec.js it('default value references previous params', () => { var f0 = function (a, b = a, c = b) { @@ -584,6 +813,39 @@ export function test({ describe, it, expect }) { expect(result).toEqual({ x: 1, y: 2 }); }); + it('for-of supports object rest in declaration and assignment patterns', () => { + const declared = []; + for (const { selected, ...rest } of [{ selected: 1, kept: 2 }]) { + declared.push([selected, rest]); + } + + let selected, rest; + for ({ selected, ...rest } of [{ selected: 3, kept: 4 }]) { + // Assignment happens in the loop head. + } + expect(declared).toEqual([[1, { kept: 2 }]]); + expect([selected, rest]).toEqual([3, { kept: 4 }]); + }); + + it('for-of supports object rest nested in an array pattern', () => { + const results = []; + for (const [index, { selected, ...rest }] of [[0, { selected: 1, kept: 2 }]]) { + results.push([index, selected, rest]); + } + expect(results).toEqual([[0, 1, { kept: 2 }]]); + }); + + it('for-await-of supports object rest', async () => { + async function* values() { + yield { selected: 1, kept: 2 }; + } + const results = []; + for await (const { selected, ...rest } of values()) { + results.push([selected, rest]); + } + expect(results).toEqual([[1, { kept: 2 }]]); + }); + // NOTE(@kitten): Broken test case // From: destructuring/for-of-shadowed-block-scoped/exec.js // @babel/plugin-transform-block-scoping bug: when it renames the inner @@ -646,6 +908,15 @@ export function test({ describe, it, expect }) { expect(rest).toEqual([2, 3, 4]); }); + it('object-rest assignment returns the right-hand value', () => { + let selected, rest; + const source = { selected: 1, kept: 2 }; + const result = ({ selected, ...rest } = source); + expect(result).toBe(source); + expect(selected).toBe(1); + expect(rest).toEqual({ kept: 2 }); + }); + // From: destructuring/chained/exec.js it('chained destructuring assignment', () => { var a, b, c, d; @@ -679,9 +950,36 @@ export function test({ describe, it, expect }) { } expect(msg).toBe('bad type'); }); + + it('collects object rest from a catch binding', () => { + let code, details; + try { + throw { code: 'E_TEST', message: 'failed', retryable: true }; + } catch ({ code: caughtCode, ...rest }) { + code = caughtCode; + details = rest; + } + expect(code).toBe('E_TEST'); + expect(details).toEqual({ message: 'failed', retryable: true }); + }); + + it('collects nested object rest from a catch binding', () => { + let details; + try { + throw { error: { message: 'failed', code: 42 } }; + } catch ({ error: { message, ...rest } }) { + details = [message, rest]; + } + expect(details).toEqual(['failed', { code: 42 }]); + }); }); describe('declaration variants', () => { + it('supports object rest in exported declarations', () => { + expect(exportedValue).toBe(1); + expect(exportedRest).toEqual({ exportedOther: 2 }); + }); + it('const with object destructuring', () => { const { x, y } = { x: 1, y: 2 }; expect(x).toBe(1); @@ -713,6 +1011,105 @@ export function test({ describe, it, expect }) { }); }); + describe('object-rest-spread regressions', () => { + // Adapted from Babel regression fixture T7178. + it('does not collide with a shadowed outer binding', () => { + const props = { outer: true }; + const inner = function () { + const { ...props } = this.props; + return props; + }.call({ props: { inner: true } }); + expect(props).toEqual({ outer: true }); + expect(inner).toEqual({ inner: true }); + }); + + // Adapted from Babel regression fixture gh-17274. + it('preserves computed-key order through nested object rest', () => { + const order = []; + const key = (value) => { + order.push(value); + return 'x'; + }; + const { + [key(0)]: { [key(1)]: first, [key(2)]: second, ...rest }, + [key(3)]: outer, + } = { x: { x: {} } }; + expect(order).toEqual([0, 1, 2, 3]); + expect(rest).toEqual({}); + expect(outer).toEqual({ x: {} }); + }); + + // Adapted from Babel regression fixture gh-4904. + it('supports nested rest and rest inside a callback parameter', () => { + const receive = (value, callback) => callback(value); + const { + nested: { selected, ...nestedRest }, + ...outerRest + } = { nested: { selected: 1, kept: 2 }, root: 3 }; + const callbackResult = receive({ selected: 4, kept: 5 }, ({ selected, ...rest }) => [ + selected, + rest, + ]); + expect([selected, nestedRest, outerRest]).toEqual([1, { kept: 2 }, { root: 3 }]); + expect(callbackResult).toEqual([4, { kept: 5 }]); + }); + + // Adapted from Babel regression fixture gh-5151. + it('makes rest bindings available to later declarators and preserves declarator order', () => { + const order = []; + const record = (name, value) => { + order.push(name); + return value; + }; + const { selected, ...rest } = record('source', { selected: 1, kept: 2 }), + later = record('later', rest.kept); + var before = record('before', true), + { + nested: { value, ...nestedRest }, + ...outerRest + } = record('nested', { nested: { value: 3, kept: 4 }, root: 5 }), + after = record('after', true); + expect([selected, rest, later]).toEqual([1, { kept: 2 }, 2]); + expect([value, nestedRest, outerRest]).toEqual([3, { kept: 4 }, { root: 5 }]); + expect([before, after]).toEqual([true, true]); + expect(order).toEqual(['source', 'later', 'before', 'nested', 'after']); + }); + + // Adapted from Babel regression fixture gh-7388. + it('transforms object rest inside deeply nested default arrow functions', () => { + function outer(value) { + const { + first = (firstValue = {}) => { + const { + second = (secondValue = {}) => { + const { selected, ...rest } = secondValue; + return [selected, rest]; + }, + } = firstValue; + return second; + }, + } = value; + return first; + } + const first = outer({}); + const second = first({}); + expect(second({ selected: 1, kept: 2 })).toEqual([1, { kept: 2 }]); + }); + + // Adapted from Babel regression fixture gh-8323. + it('preserves defaults when an object parameter also contains rest', () => { + let defaults = 0; + const fallback = () => { + defaults++; + return 3; + }; + const extract = ({ a = fallback(), b, c, ...rest }) => [a, b, c, rest]; + expect(extract({ b: 2, c: 3, kept: 4 })).toEqual([3, 2, 3, { kept: 4 }]); + expect(extract({ a: 1, b: 2, c: 3, kept: 4 })).toEqual([1, 2, 3, { kept: 4 }]); + expect(defaults).toBe(1); + }); + }); + // From: destructuring/check-iterator-return/exec.js describe('iterator protocol', () => { it('empty pattern calls iterator.return when return() returns object', () => { diff --git a/docs/common/code-utilities.ts b/docs/common/code-utilities.ts index fcceabff3bb982..46fafbbb25bae0 100644 --- a/docs/common/code-utilities.ts +++ b/docs/common/code-utilities.ts @@ -1,4 +1,3 @@ -import partition from 'lodash/partition'; import { Language, Prism } from 'prism-react-renderer'; import { Children, ReactElement, ReactNode, PropsWithChildren, isValidElement } from 'react'; @@ -208,6 +207,15 @@ export function replaceSlashCommentsWithAnnotationsForTutorial(value: string) { ); } +function partition(items: T[], predicate: (item: T) => boolean): [T[], T[]] { + const matched: T[] = []; + const rest: T[] = []; + for (const item of items) { + (predicate(item) ? matched : rest).push(item); + } + return [matched, rest]; +} + export function parseValue(value: string) { if (value.startsWith('@@@')) { const valueChunks = value.split('@@@'); diff --git a/docs/common/window.ts b/docs/common/window.ts index 58f731fd51eeda..5b40f34afeeed0 100644 --- a/docs/common/window.ts +++ b/docs/common/window.ts @@ -14,10 +14,10 @@ export function getViewportSize() { }; } -export function prefersDarkTheme() { - return window?.matchMedia('(prefers-color-scheme: dark)').matches ?? false; -} - export function prefersReducedMotion() { return window?.matchMedia('(prefers-reduced-motion)').matches ?? false; } + +export function isDarkTheme() { + return document.documentElement.classList.contains('dark-theme'); +} diff --git a/docs/pages/accounts/audit-logs.mdx b/docs/pages/accounts/audit-logs.mdx index 8608c3db142a71..060966d743f962 100644 --- a/docs/pages/accounts/audit-logs.mdx +++ b/docs/pages/accounts/audit-logs.mdx @@ -4,6 +4,7 @@ description: Learn how to track and analyze your account's activities by using t --- import { ContentSpotlight } from '~/ui/components/ContentSpotlight'; +import { Terminal } from '~/ui/components/Snippet'; > **info** Audit logs are available for [Enterprise plan](https://expo.dev/pricing) customers. @@ -14,7 +15,7 @@ Audit logs record actions made with Expo Application Services (EAS) by accounts. - Audit logs can only be created and never modified or deleted, they serve as a source of truth to help monitor events and debug issues occurring within accounts. - **Audit logs are available to Enterprise plan customers**. When subscribed, some of the logs used internally by Expo are immediately available, while other types of logs are starting to be collected after the subscription is activated. - Audit logs are stored for 1.5 years. If an account is deleted, its audit logs will be deleted after 90 days. -- To access them, go to **Account settings**/**Organization settings** > [**Audit logs**](https://expo.dev/accounts/[account]/settings/audit-logs). +- To access them, go to **Account settings**/**Organization settings** > [**Audit logs**](https://expo.dev/accounts/[account]/settings/audit-logs), or use [EAS CLI](#view-audit-logs-with-eas-cli). **Note:** Export is currently only available through the Expo website. There is no API available for programmatic export of audit logs. +> **Note:** The **Export** button is only available on the Expo website. To read audit logs programmatically, use [EAS CLI](#view-audit-logs-with-eas-cli). + +## View audit logs with EAS CLI + +You can also programmatically read audit logs from a terminal. Run the `eas account:audit` command with the account name: + + + +If you omit the account name, the command prompts you to select one. To read the logs in a script, pass the `--json` flag: + + + +For pagination and the remaining flags, see the [`eas account:audit`](/eas/cli/#eas-account-audit-account-name) reference. diff --git a/docs/pages/router/advanced/shared-routes.mdx b/docs/pages/router/advanced/shared-routes.mdx index e74fc2d59aeac0..6fdae531a4633d 100644 --- a/docs/pages/router/advanced/shared-routes.mdx +++ b/docs/pages/router/advanced/shared-routes.mdx @@ -21,9 +21,11 @@ In the example below, **src/app/\_layout.tsx** is the tab bar and each route has ]} /> -> When reloading the page, the first alphabetical match is rendered. +Group segments are not part of the URL, so every shared route matches the same URL. Expo Router uses the group you are in to select between them. In-app navigation keeps the current group. A page reload, a bookmark, a shared URL, and a deep link are all cold links. A cold link has no current group, so Expo Router renders the first alphabetical match. The same URL can therefore render one screen after in-app navigation and a different screen after a page reload. -Shared routes can be navigated directly by including the group name in the route. For example, `/(search)/baconbrix` navigates to `/baconbrix` in the "search" layout. +Shared routes can be navigated directly by including the group name in the route. For example, `/(search)/baconbrix` navigates to `/baconbrix` in the "search" layout. Use this form when a link must always open one specific group. + +> **warning** Do not use shared routes to give different user roles a different version of a screen. The URL does not carry the user's role, so a cold link cannot select the correct group, and [protected routes](/router/advanced/protected/) do not change that. Declare the screen once and control access with `Stack.Protected`. ## Arrays diff --git a/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx b/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx index f9aab6c2b9f542..c0594bc9c9ca62 100644 --- a/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx +++ b/docs/scenes/get-started/set-up-your-environment/SelectCard.tsx @@ -1,4 +1,4 @@ -import { ButtonBase, mergeClasses, useTheme } from '@expo/styleguide'; +import { ButtonBase, mergeClasses } from '@expo/styleguide'; import { CALLOUT, HEADLINE } from '~/ui/components/Text'; @@ -21,7 +21,7 @@ export function SelectCard({ isSelected, onClick, }: Props) { - const { themeName } = useTheme(); + const imageClasses = isSelected ? 'grayscale-0' : 'opacity-80 grayscale'; return ( @@ -35,20 +35,30 @@ export function SelectCard({ 'border-b border-default', isSelected ? 'bg-linear-to-b from-palette-blue3 to-palette-blue4' : 'bg-subtle' )}> - - {themeName !== 'light' && ( - - )} + {alt} + {darkImgSrc && ( + + {alt} + + )}
diff --git a/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx b/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx index 1f68c8fc35afb1..44a4027d727392 100644 --- a/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx +++ b/docs/scenes/get-started/start-developing/TemplateFeatures/Content.tsx @@ -1,8 +1,6 @@ -import { Button, useTheme } from '@expo/styleguide'; +import { Button, mergeClasses } from '@expo/styleguide'; import { ArrowRightIcon } from '@expo/styleguide-icons/outline/ArrowRightIcon'; -import { ReactNode, useEffect, useState } from 'react'; - -import { prefersDarkTheme } from '~/common/window'; +import { ReactNode } from 'react'; type Props = { imgSrc: string; @@ -13,23 +11,23 @@ type Props = { }; export function Content({ imgSrc, darkImgSrc, alt, href, content }: Props) { - const { themeName } = useTheme(); - const [isDarkMode, setIsDarkMode] = useState(false); - - useEffect( - function didMount() { - setIsDarkMode(themeName === 'dark' || (themeName === 'auto' && prefersDarkTheme())); - }, - [themeName] - ); - return (
- - {isDarkMode && } - {alt} + + {alt} + {darkImgSrc && ( + + {alt} + + )}
diff --git a/docs/tailwind.config.cjs b/docs/tailwind.config.cjs index 9bdc85d4561305..7ae1f2c92b522a 100644 --- a/docs/tailwind.config.cjs +++ b/docs/tailwind.config.cjs @@ -1,5 +1,10 @@ const expoTheme = require('@expo/styleguide/tailwind'); const merge = require('lodash/merge'); +const plugin = require('tailwindcss/plugin'); + +const lightVariant = plugin(({ addVariant }) => { + addVariant('light', '&:is(:root:not([class*="dark-theme"]) *)'); +}); function getExpoTheme(extend = {}, plugins = []) { const customizedTheme = Object.assign({}, expoTheme); @@ -21,45 +26,48 @@ module.exports = { './node_modules/@expo/styleguide-search-ui/dist/**/*.{js,ts,jsx,tsx}', './node_modules/@expo/styleguide-cookie-consent/dist/**/*.{js,ts,jsx,tsx}', ], - ...getExpoTheme({ - screens: { - sm: '468px', - md: '788px', - lg: '1008px', - xl: '1328px', - '2xl': '1572px', - }, - borderColor: { - 'palette-orange3.5': 'hsl(from var(--orange-4) h calc(s - 5) calc(l + 5));', - }, - backgroundImage: { - 'cell-quickstart-pattern': "url('/static/images/home/QuickStartPattern.svg')", - 'cell-tutorial-pattern': "url('/static/images/home/TutorialPattern.svg')", - 'cell-workflows-pattern': "url('/static/images/home/WorkflowsPattern.svg')", - }, - keyframes: { - wave: { - '0%, 100%': { - transform: 'rotate(0deg)', - }, - '50%': { - transform: 'rotate(20deg)', - }, + ...getExpoTheme( + { + screens: { + sm: '468px', + md: '788px', + lg: '1008px', + xl: '1328px', + '2xl': '1572px', + }, + borderColor: { + 'palette-orange3.5': 'hsl(from var(--orange-4) h calc(s - 5) calc(l + 5));', + }, + backgroundImage: { + 'cell-quickstart-pattern': "url('/static/images/home/QuickStartPattern.svg')", + 'cell-tutorial-pattern': "url('/static/images/home/TutorialPattern.svg')", + 'cell-workflows-pattern': "url('/static/images/home/WorkflowsPattern.svg')", }, - slideUpAndFadeIn: { - '0%': { - opacity: 0, - transform: 'translateY(16px)', + keyframes: { + wave: { + '0%, 100%': { + transform: 'rotate(0deg)', + }, + '50%': { + transform: 'rotate(20deg)', + }, }, - '100%': { - opacity: 1, - transform: 'translateY(0)', + slideUpAndFadeIn: { + '0%': { + opacity: 0, + transform: 'translateY(16px)', + }, + '100%': { + opacity: 1, + transform: 'translateY(0)', + }, }, }, + animation: { + slideUpAndFadeIn: 'slideUpAndFadeIn 0.25s ease-out', + wave: 'wave 0.25s ease-in-out 4', + }, }, - animation: { - slideUpAndFadeIn: 'slideUpAndFadeIn 0.25s ease-out', - wave: 'wave 0.25s ease-in-out 4', - }, - }), + [lightVariant] + ), }; diff --git a/docs/ui/components/ComponentExample.tsx b/docs/ui/components/ComponentExample.tsx index 1c13d9deac1881..ba3a3ed75d76e4 100644 --- a/docs/ui/components/ComponentExample.tsx +++ b/docs/ui/components/ComponentExample.tsx @@ -1,9 +1,7 @@ -import { useTheme } from '@expo/styleguide'; import { FileCode01Icon } from '@expo/styleguide-icons/outline/FileCode01Icon'; -import { PropsWithChildren, useEffect, useState } from 'react'; +import { PropsWithChildren } from 'react'; import { cleanCopyValue, getCodeBlockDataFromChildren } from '~/common/code-utilities'; -import { prefersDarkTheme } from '~/common/window'; import { usePageApiVersion } from '~/providers/page-api-version'; import { LightboxImage } from '~/ui/components/ContentSpotlight/LightboxImage'; import { PlatformTabs } from '~/ui/components/PlatformTabs'; @@ -89,25 +87,14 @@ function resolveImages({ * every capture carries as `-ios-` or `-android-`, so pages need no extra prop. */ export function ComponentExample({ title, src, darkSrc, alt, android, ios, children }: Props) { - const { themeName } = useTheme(); const context = usePageApiVersion(); - const [isDark, setDark] = useState(false); const images = resolveImages({ src, darkSrc, alt, android, ios }); const available = orderPlatforms(images); const { active, select } = usePlatformSelection(available); - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); - const { value } = getCodeBlockDataFromChildren(children); const image = images[active]; - const activeSrc = image && isDark && image.darkSrc ? image.darkSrc : image?.src; const device = active === 'android' ? DEVICE_FRAMES.android : DEVICE_FRAMES.ios; return ( @@ -120,7 +107,7 @@ export function ComponentExample({ title, src, darkSrc, alt, android, ios, child {children}
- {image && activeSrc && ( + {image && (
import('./LightboxModal'), { ssr: false }); type Props = ImgHTMLAttributes & { src: string; + darkSrc?: string; }; -export function LightboxImage({ src, alt, ...rest }: Props) { +export function LightboxImage({ src, darkSrc, alt, className, ...rest }: Props) { const [open, setOpen] = useState(false); - const [lightboxRequested, setLightboxRequested] = useState(false); + const [lightboxSrc, setLightboxSrc] = useState(); return ( <> - {lightboxRequested && ( + {lightboxSrc && ( { setOpen(false); diff --git a/docs/ui/components/ContentSpotlight/index.tsx b/docs/ui/components/ContentSpotlight/index.tsx index 84d517c3f5a8f0..a36e4578a7d8c5 100644 --- a/docs/ui/components/ContentSpotlight/index.tsx +++ b/docs/ui/components/ContentSpotlight/index.tsx @@ -1,9 +1,8 @@ -import { mergeClasses, useTheme } from '@expo/styleguide'; +import { mergeClasses } from '@expo/styleguide'; import { useInView } from 'framer-motion'; import dynamic from 'next/dynamic'; -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; -import { prefersDarkTheme } from '~/common/window'; import { DotGrid } from '~/ui/components/Diagram/DotGrid'; import { LightboxImage } from './LightboxImage'; @@ -61,16 +60,6 @@ export function ContentSpotlight({ aspect, }: ContentSpotlightProps) { const [forceShowControls, setForceShowControls] = useState(); - const { themeName } = useTheme(); - const [isDark, setDark] = useState(themeName === 'dark'); - - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); const resolvedPlayerWidth = playerWidth ?? PLAYER_WIDTH; const resolvedPlayerHeight = playerHeight ?? PLAYER_HEIGHT; @@ -87,7 +76,6 @@ export function ContentSpotlight({ const shouldAutoplay = isInView && isVideo && (!videoId || autoplayYT); const isComponentVariant = variant === 'component' && !isVideo; - const activeSrc = isDark && darkSrc ? darkSrc : src; return (
- - {isDark && darkSrc && } + {alt} + {darkSrc && ( + + {alt} + + )} ) : src ? ( { - const { themeName } = useTheme(); - const [isDark, setDark] = useState(themeName === 'dark'); +type PictureProps = { + src: string; + alt: string; + withFormats: boolean; + isPaired: boolean; + className: string; +}; - useEffect(() => { - if (themeName === 'auto') { - setDark(prefersDarkTheme()); - } else { - setDark(themeName === 'dark'); - } - }, [themeName]); +function DiagramPicture({ src, alt, withFormats, isPaired, className }: PictureProps) { + return ( + + {withFormats && } + {withFormats && } + {alt} + + ); +} - if (!source.endsWith('.png')) { - return ( -
- - - {isDark && darkSource && } - {alt} - -
- ); - } +export const Diagram = ({ source, darkSource, disableSrcSet, alt }: Props) => { + const withFormats = source.endsWith('.png') && !disableSrcSet; return (
- - {!isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && !disableSrcSet && ( - - )} - {!isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && !disableSrcSet && ( - - )} - {darkSource && isDark && } - {alt} - + + {darkSource && ( + + )}
); }; diff --git a/docs/ui/components/EASHostingShoutoutBanner.tsx b/docs/ui/components/EASHostingShoutoutBanner.tsx deleted file mode 100644 index 75be5e25b123ac..00000000000000 --- a/docs/ui/components/EASHostingShoutoutBanner.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { Button, mergeClasses } from '@expo/styleguide'; -import { ArrowUpRightIcon } from '@expo/styleguide-icons/outline/ArrowUpRightIcon'; -import { Cloud01Icon } from '@expo/styleguide-icons/outline/Cloud01Icon'; -import { XIcon } from '@expo/styleguide-icons/outline/XIcon'; -import { useEffect, useState } from 'react'; - -import { useLocalStorage } from '~/common/useLocalStorage'; - -export function EASHostingShoutoutBanner() { - const [isLoaded, setIsLoaded] = useState(false); - const [lastDismissDate, setLastDismissDate] = useLocalStorage({ - name: 'eas-hosting-shoutout', - defaultValue: null, - }); - - useEffect(function didMount() { - setIsLoaded(true); - }, []); - - if (lastDismissDate || !isLoaded) { - return null; - } - - return ( -
- -
-
-
-
-
-

EAS Hosting

-

- Try the first end-to-end deployment solution for universal app development. -

-
-
-
- -
-
- ); -} diff --git a/docs/ui/components/Search/Search.tsx b/docs/ui/components/Search/Search.tsx index d58ded3441d997..0bd57cdf18731b 100644 --- a/docs/ui/components/Search/Search.tsx +++ b/docs/ui/components/Search/Search.tsx @@ -5,7 +5,6 @@ import { lazy, ReactNode, Suspense, useEffect, useRef, useState } from 'react'; import { usePageApiVersion } from '~/providers/page-api-version'; import versions from '~/public/static/constants/versions.json'; -import { ExpoDashboardItem } from './ExpoDashboardItem'; import { entries } from './expoEntries'; const CommandMenu = lazy(() => @@ -57,6 +56,7 @@ export const Search = ({ mainSection }: SearchProps) => { const filteredEntries = entries.filter(entry => entry.label.toLowerCase().includes(query.toLowerCase()) ); + const { ExpoDashboardItem } = await import('./ExpoDashboardItem'); setExpoDashboardItems( filteredEntries.map(item => ) ); diff --git a/docs/ui/components/Search/expoEntries.ts b/docs/ui/components/Search/expoEntries.ts index 2b19283727ec5b..44d4e3c3dd6732 100644 --- a/docs/ui/components/Search/expoEntries.ts +++ b/docs/ui/components/Search/expoEntries.ts @@ -3,16 +3,22 @@ import { BranchIcon } from '@expo/styleguide-icons/custom/BranchIcon'; import { BuildIcon } from '@expo/styleguide-icons/custom/BuildIcon'; import { CredentialIcon } from '@expo/styleguide-icons/custom/CredentialIcon'; import { EasSubmitIcon } from '@expo/styleguide-icons/custom/EasSubmitIcon'; +import { GithubIcon } from '@expo/styleguide-icons/custom/GithubIcon'; import { Smartphone01Icon } from '@expo/styleguide-icons/custom/Smartphone01Icon'; import { Cloud01DuotoneIcon } from '@expo/styleguide-icons/duotone/Cloud01DuotoneIcon'; import { Fingerprint03DuotoneIcon } from '@expo/styleguide-icons/duotone/Fingerprint03DuotoneIcon'; +import { ActivityHeartIcon } from '@expo/styleguide-icons/outline/ActivityHeartIcon'; +import { BarChart01Icon } from '@expo/styleguide-icons/outline/BarChart01Icon'; import { BracketsXIcon } from '@expo/styleguide-icons/outline/BracketsXIcon'; +import { CpuChip01Icon } from '@expo/styleguide-icons/outline/CpuChip01Icon'; import { Cube02Icon } from '@expo/styleguide-icons/outline/Cube02Icon'; import { DataIcon } from '@expo/styleguide-icons/outline/DataIcon'; +import { Database01Icon } from '@expo/styleguide-icons/outline/Database01Icon'; import { Dataflow03Icon } from '@expo/styleguide-icons/outline/Dataflow03Icon'; import { FileSearch02Icon } from '@expo/styleguide-icons/outline/FileSearch02Icon'; import { Grid01Icon } from '@expo/styleguide-icons/outline/Grid01Icon'; import { LayersTwo02Icon } from '@expo/styleguide-icons/outline/LayersTwo02Icon'; +import { Monitor01Icon } from '@expo/styleguide-icons/outline/Monitor01Icon'; import { NotificationBoxIcon } from '@expo/styleguide-icons/outline/NotificationBoxIcon'; import { Settings01Icon } from '@expo/styleguide-icons/outline/Settings01Icon'; import type { ComponentType, HTMLAttributes } from 'react'; @@ -59,6 +65,16 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/insights', Icon: DataIcon, }, + { + label: 'Project usage', + url: 'https://expo.dev/accounts/[account]/projects/[project]/usage', + Icon: BarChart01Icon, + }, + { + label: 'Project observe', + url: 'https://expo.dev/accounts/[account]/projects/[project]/observe', + Icon: ActivityHeartIcon, + }, { label: 'Project workflows', url: 'https://expo.dev/accounts/[account]/projects/[project]/workflows', @@ -69,6 +85,11 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/development-builds', Icon: Smartphone01Icon, }, + { + label: 'Project simulator sessions', + url: 'https://expo.dev/accounts/[account]/projects/[project]/simulator-sessions', + Icon: Monitor01Icon, + }, { label: 'Project builds', url: 'https://expo.dev/accounts/[account]/projects/[project]/builds', @@ -94,6 +115,11 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/updates', Icon: LayersTwo02Icon, }, + { + label: 'Project update runtimes', + url: 'https://expo.dev/accounts/[account]/projects/[project]/runtimes', + Icon: CpuChip01Icon, + }, { label: 'Project hosting', url: 'https://expo.dev/accounts/[account]/projects/[project]/hosting', @@ -104,9 +130,14 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/push-notifications', Icon: NotificationBoxIcon, }, + { + label: 'Project caches', + url: 'https://expo.dev/accounts/[account]/projects/[project]/caches', + Icon: Database01Icon, + }, { label: 'Project fingerprints', - url: 'https://expo.dev/accounts/[account]/projects/[project]/distribution', + url: 'https://expo.dev/accounts/[account]/projects/[project]/fingerprints', Icon: Fingerprint03DuotoneIcon, }, { @@ -124,4 +155,9 @@ export const entries: ExpoItemType[] = [ url: 'https://expo.dev/accounts/[account]/projects/[project]/environment-variables', Icon: BracketsXIcon, }, + { + label: 'Project GitHub', + url: 'https://expo.dev/accounts/[account]/projects/[project]/github', + Icon: GithubIcon, + }, ]; diff --git a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts index d84cde1ee5a170..2a0f1ca812433c 100644 --- a/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts +++ b/packages/@expo/metro-config/src/transform-worker/__tests__/noxcturnal/expo-plugins.test.ts @@ -704,19 +704,104 @@ it('uses the Babel WebView preflight for DOM components', async () => { expect(result.result.code).toContain('decorate'); }); -it('matches Babel by lowering object spread for a modern non-Hermes target', async () => { - const result = await transformFileFullyWithNoxcturnal({ - filename: '/app/src/web.js', - projectRoot: '/app', - source: 'module.exports = { ...source, value: 1 };', - options: options({ platform: 'web', customTransformOptions: { engine: undefined } }), - isDefaultExpoTransformer: true, - config: fullConfig(), +describe('object rest/spread transform profiles', () => { + it.each([ + ['Hermes v0', options({ unstable_transformProfile: 'default' })], + [ + 'WebView', + options({ + unstable_transformProfile: 'hermes-stable', + customTransformOptions: { engine: 'hermes', dom: 'true' }, + }), + ], + ])('lowers object spread for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/native.js', + projectRoot: '/app', + source: 'module.exports = { ...source, value: 1 };', + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).not.toContain('...source'); }); - expect(result.status).toBe('complete'); - if (result.status !== 'complete') return; - expect(result.result.code).not.toContain('...source'); + it.each([ + ['Hermes v1', options({ unstable_transformProfile: 'hermes-stable' })], + [ + 'web', + options({ + platform: 'web', + customTransformOptions: { engine: undefined }, + }), + ], + [ + 'server', + options({ + customTransformOptions: { engine: 'hermes', environment: 'node' }, + }), + ], + ])('preserves object spread for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/modern.js', + projectRoot: '/app', + source: 'module.exports = { ...source, value: 1 };', + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).toContain('...source'); + }); + + it.each([ + ['Hermes v0', options({ unstable_transformProfile: 'default' })], + [ + 'WebView', + options({ + unstable_transformProfile: 'hermes-stable', + customTransformOptions: { engine: 'hermes', dom: 'true' }, + }), + ], + ])('preserves computed-key exclusion semantics for %s', async (_profile, transformOptions) => { + const result = await transformFileFullyWithNoxcturnal({ + filename: '/app/src/native.js', + projectRoot: '/app', + source: `function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + module.exports = omit({ a: 1, b: 2 }, { key: 'a' });`, + options: transformOptions, + isDefaultExpoTransformer: true, + config: fullConfig(), + }); + + expect(result.status).toBe('complete'); + if (result.status !== 'complete') return; + expect(result.result.code).not.toContain('...rest'); + + let factory: Function | undefined; + new Function('__d', result.result.code)((value: Function) => { + factory = value; + }); + const module = { exports: {} as unknown }; + factory?.( + globalThis, + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + (_id: unknown, name: string) => requireFromBabelPresetExpo(name), + module, + module.exports, + [] + ); + expect(module.exports).toEqual({ b: 2 }); + }); }); it('matches Babel by preserving async generators on modern web targets', async () => { diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts index ae81ba6bc49622..20a4e4eab1a088 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/hermes-v0.ts @@ -13,6 +13,7 @@ export function getHermesV0PreflightConfig(facts: ProfilePreflightFacts): Profil destructuring: true, asyncGeneratorFunctions: facts.hasAsyncGenerator, asyncFunctions: facts.hasAsync, + objectRestSpread: facts.hasSpread ? { loose: true, useBuiltIns: true } : undefined, parameters: true, }; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts index 9d19e87f84c801..2c889a65f20a52 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/types.ts @@ -8,6 +8,7 @@ export interface ProfilePreflightFacts { hasForOf: boolean; hasPrivateSyntax: boolean; hasRegexpLiteral: boolean; + hasSpread: boolean; hasStaticBlock: boolean; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts index 4aa6934f2cb53a..3279252dbdfd1c 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/configs/webview.ts @@ -19,5 +19,6 @@ export function getWebViewPreflightConfig(facts: ProfilePreflightFacts): Profile optionalChaining: { loose: true }, nullishCoalescingOperator: { loose: true }, logicalAssignmentOperators: true, + objectRestSpread: facts.hasSpread ? { loose: true, useBuiltIns: true } : undefined, }; } diff --git a/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts b/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts index 79d64f30cf8166..ae00e99c4d7e34 100644 --- a/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts +++ b/packages/@expo/metro-config/src/transform-worker/noxcturnal/noxcturnal-transformer.ts @@ -678,8 +678,6 @@ function createProfilePreflight( const hasAsyncCandidate = sourceFacts.hasAsync; const hasRegexpLiteralCandidate = sourceFacts.hasSlash; const hasDecoratorCandidate = /@\w/.test(input.source); - const nonHermes = options.customTransformOptions?.engine !== 'hermes'; - const hasObjectRestSpread = nonHermes && hasSpreadCandidate; const profileTransforms = getProfilePreflightConfig(input, { hasAsync: hasAsyncCandidate, hasAsyncGenerator, @@ -688,15 +686,12 @@ function createProfilePreflight( hasForOf: hasForOfCandidate, hasPrivateSyntax: sourceFacts.hasPrivateSyntax, hasRegexpLiteral: hasRegexpLiteralCandidate, + hasSpread: hasSpreadCandidate, hasStaticBlock, }); const hasProfileWork = Object.values(profileTransforms).some(Boolean); const hasOtherLanguageWork = - isTypeScript || - enableReactRefresh || - hasProfileWork || - hasDecoratorCandidate || - hasObjectRestSpread; + isTypeScript || enableReactRefresh || hasProfileWork || hasDecoratorCandidate; // Avoid a parse/codegen boundary for the overwhelmingly common file that has // none of this recipe's language work. Flow without JSX is already rendered by // its focused native erasure and does not need a second print. @@ -706,7 +701,6 @@ function createProfilePreflight( !mayContainJsx && !hasProfileWork && !hasDecoratorCandidate && - !hasObjectRestSpread && !enableReactRefresh ) { return null; @@ -715,12 +709,6 @@ function createProfilePreflight( transforms: { ...profileTransforms, legacyDecorators: hasDecoratorCandidate, - objectRestSpread: hasObjectRestSpread - ? { - loose: true, - useBuiltIns: true, - } - : undefined, }, // Expo owns this recipe. These choices mirror its Hermes-v1 and React configs // without exposing either config name through Noxcturnal's capability API. diff --git a/packages/babel-preset-expo/CHANGELOG.md b/packages/babel-preset-expo/CHANGELOG.md index 1eaedcdb41b193..18e525045b5834 100644 --- a/packages/babel-preset-expo/CHANGELOG.md +++ b/packages/babel-preset-expo/CHANGELOG.md @@ -12,6 +12,7 @@ - Escape backslashes when serializing `'widget'` functions so escape sequences like `\n` in a widget layout survive the template-literal round-trip instead of corrupting the stored function and crashing the widget with `SyntaxError: Unexpected EOF`. ([#47626](https://github.com/expo/expo/pull/47626) by [@alecmolloy](https://github.com/alecmolloy)) - Fix legacy decorators on class properties when corresponding class transforms are disabled, which the decorators plugin relies on ([#47724](https://github.com/expo/expo/pull/47724) by [@Gitarcitano](https://github.com/Gitarcitano), [@kitten](https://github.com/kitten)) +- Disable `@babel/plugin-transform-object-rest-spread` in Hermes v1 and Modern Web sub-presets. The ordering dependence on `@babel/plugin-transform-destructuring` could cause computed exclusion to be missed ([#49278](https://github.com/expo/expo/pull/49278) by [@kitten](https://github.com/kitten)) ### 💡 Others diff --git a/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts b/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts index 0de98ad24ffd14..c3f0819f815f71 100644 --- a/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts +++ b/packages/babel-preset-expo/src/__tests__/hermes-bytecode.test.ts @@ -281,8 +281,10 @@ const LANGUAGE_SAMPLES: { code: `var y = {}; var x = 1; var k = { x, ...y };`, - getCompiledCode() { - return `var y={};var x=1;var k={x,...y};`; + getCompiledCode({ platform }) { + return platform === 'web' + ? `var y={};var x=1;var k={x,...y};` + : `var y={};var x=1;var k=Object.assign({x},y);`; }, }, { diff --git a/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts b/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts new file mode 100644 index 00000000000000..8fe2cdf7014cfc --- /dev/null +++ b/packages/babel-preset-expo/src/__tests__/object-rest-spread.test.ts @@ -0,0 +1,67 @@ +import * as babel from '@babel/core'; + +import preset from '..'; + +jest.mock('../utils/resolveModule.ts', () => ({ + ...jest.requireActual('../utils/resolveModule.ts'), + resolveModule: jest.fn(() => null), + hasModule: jest.fn(() => false), +})); + +const profiles = [ + ['web', { name: 'metro', platform: 'web', isDev: true }, false], + ['hermes-v1', { name: 'metro', platform: 'ios', engine: 'hermes', isDev: true }, false], + ['hermes-v0', { name: 'metro', platform: 'ios', isDev: true }, true], + ['webview', { name: 'metro', platform: 'web', isDomComponent: true, isDev: true }, true], +] as const; + +function transformObjectRest(caller: Record): string { + const result = babel.transformSync( + ` + function omit(obj, spec) { + const { [spec.key]: unused, ...rest } = obj; + return rest; + } + `, + { + babelrc: false, + configFile: false, + filename: '/test.js', + presets: [[preset, { enableBabelRuntime: false }]], + caller: caller as babel.TransformCaller, + } + ); + if (!result?.code) throw new Error('Babel transform returned no code'); + return result.code; +} + +describe('object-rest-spread transforms', () => { + it.each(profiles)('%s selects the expected transform', (_name, caller, transformsObjectRest) => { + const code = transformObjectRest(caller); + + expect(code.includes('...rest')).toBe(!transformsObjectRest); + expect(code.includes('objectWithoutProperties')).toBe(transformsObjectRest); + }); +}); + +describe('computed object-rest exclusions', () => { + it.each(profiles.filter(([, , transformsObjectRest]) => transformsObjectRest))( + '%s assigns the computed key temporary before the loose object-rest transform', + (_name, caller) => { + const code = transformObjectRest(caller); + + expect(code).toMatch(/var _spec\$key\s*=\s*spec\.key/); + expect(code).not.toMatch(/var _spec\$key\s*;/); + } + ); + + it.each(profiles)('%s preserves the computed exclusion key', (_name, caller) => { + const code = transformObjectRest(caller); + const omit = new Function(`${code}; return omit;`)() as ( + obj: Record, + spec: { key: string } + ) => Record; + + expect(omit({ a: 1, b: 2 }, { key: 'a' })).toEqual({ b: 2 }); + }); +}); diff --git a/packages/babel-preset-expo/src/__tests__/preset-config.test.ts b/packages/babel-preset-expo/src/__tests__/preset-config.test.ts index eba67dd15466a4..946cb4ed4238bf 100644 --- a/packages/babel-preset-expo/src/__tests__/preset-config.test.ts +++ b/packages/babel-preset-expo/src/__tests__/preset-config.test.ts @@ -60,7 +60,6 @@ describe('plugin list snapshots', () => { "transform-flow-strip-types", "transform-typescript", "base$0$2$0", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "expo-inline-manifest-plugin", @@ -88,6 +87,7 @@ describe('plugin list snapshots', () => { "transform-destructuring", "transform-async-generator-functions", "transform-async-to-generator", + "transform-object-rest-spread", "transform-parameters", "transform-react-display-name", "transform-runtime", @@ -213,7 +213,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -259,7 +258,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-minify-platform-select", "expo-inline-or-reference-env-vars", @@ -307,7 +305,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-manifest-plugin", "expo-router", @@ -352,7 +349,6 @@ describe('plugin list snapshots', () => { "transform-flow-enums", "transform-flow-strip-types", "transform-typescript", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -400,7 +396,6 @@ describe('plugin list snapshots', () => { "transform-flow-strip-types", "transform-typescript", "base$0$2$0", - "transform-object-rest-spread", "expo-define-globals", "expo-inline-or-reference-env-vars", "Rewrite react-native to react-native-web", @@ -436,6 +431,7 @@ describe('plugin list snapshots', () => { "transform-optional-chaining", "transform-nullish-coalescing-operator", "transform-logical-assignment-operators", + "transform-object-rest-spread", "transform-runtime", "transform-export-namespace-from", "proposal-export-default-from", @@ -560,13 +556,23 @@ describe('isDomComponent', () => { }); }); -describe('engine-driven plugins', () => { - it('adds transform-object-rest-spread for non-hermes engine', () => { +describe('profile-driven plugins', () => { + it('adds transform-object-rest-spread for the hermes-v0 profile', () => { const keys = getPluginKeys({ name: 'metro', platform: 'ios', isDev: true }); expect(keys).toContain('transform-object-rest-spread'); }); - it('does not add transform-object-rest-spread for hermes engine', () => { + it('adds transform-object-rest-spread for the webview profile', () => { + const keys = getPluginKeys({ + name: 'metro', + platform: 'web', + isDomComponent: true, + isDev: true, + }); + expect(keys).toContain('transform-object-rest-spread'); + }); + + it('does not add transform-object-rest-spread for the hermes-v1 profile', () => { const keys = getPluginKeys({ name: 'metro', engine: 'hermes', @@ -576,6 +582,13 @@ describe('engine-driven plugins', () => { expect(keys).not.toContain('transform-object-rest-spread'); }); + it.each([ + ['web', { name: 'metro', platform: 'web', isDev: true }], + ['server', { name: 'metro', platform: 'ios', isServer: true, isDev: true }], + ])('does not add transform-object-rest-spread for the modern %s profile', (_name, caller) => { + expect(getPluginKeys(caller)).not.toContain('transform-object-rest-spread'); + }); + it('adds transform-parameters for default engine (hermes-v0)', () => { const keys = getPluginKeys({ name: 'metro', platform: 'ios', isDev: true }); expect(keys).toContain('transform-parameters'); diff --git a/packages/babel-preset-expo/src/configs/expo.ts b/packages/babel-preset-expo/src/configs/expo.ts index 1a8d7bc17b73d9..314b6ef7e1a5d6 100644 --- a/packages/babel-preset-expo/src/configs/expo.ts +++ b/packages/babel-preset-expo/src/configs/expo.ts @@ -64,18 +64,6 @@ module.exports = function (api: ConfigAPI, options: ExpoConfigOptions) { plugins.push(reactCompilerPlugin); } - // TODO(@kitten): Remove or add non-hermes config - if (options.engine !== 'hermes') { - // `@react-native/babel-preset` configures this plugin with `{ loose: true }`, which breaks all - // getters and setters in spread objects. We need to add this plugin ourself without that option. - // @see https://github.com/expo/expo/pull/11960#issuecomment-887796455 - plugins.push([ - require('@babel/plugin-transform-object-rest-spread'), - // Assume no dependence on getters or evaluation order. See https://github.com/babel/babel/pull/11520 - { loose: true, useBuiltIns: true }, - ]); - } - const inlines = getInlinesFromOptions(options); plugins.push([require('../plugins/define-plugin'), inlines]); diff --git a/packages/babel-preset-expo/src/configs/hermes-v0.ts b/packages/babel-preset-expo/src/configs/hermes-v0.ts index 4c6698145555f9..56505c840bfb0d 100644 --- a/packages/babel-preset-expo/src/configs/hermes-v0.ts +++ b/packages/babel-preset-expo/src/configs/hermes-v0.ts @@ -24,6 +24,9 @@ module.exports = function (_api: ConfigAPI, _options: HermesV0ConfigOptions) { [require('@babel/plugin-transform-destructuring'), { useBuiltIns: true }], [require('@babel/plugin-transform-async-generator-functions')], [require('@babel/plugin-transform-async-to-generator')], + // Keep this after transform-destructuring to avoid the loose-mode computed exclusion bug. + // See: https://github.com/babel/babel/pull/18197 + [require('@babel/plugin-transform-object-rest-spread'), { loose: true, useBuiltIns: true }], // Ensure the react-jsx-dev plugin works as expected when JSX is used in a function body. require('@babel/plugin-transform-parameters'), [require('@babel/plugin-transform-react-display-name')], diff --git a/packages/babel-preset-expo/src/configs/webview.ts b/packages/babel-preset-expo/src/configs/webview.ts index 339ffc32aa5745..47fcaf4607c4b2 100644 --- a/packages/babel-preset-expo/src/configs/webview.ts +++ b/packages/babel-preset-expo/src/configs/webview.ts @@ -33,6 +33,9 @@ module.exports = function (_api: ConfigAPI, _options: WebviewConfigOptions) { [require('@babel/plugin-transform-optional-chaining'), { loose: true }], [require('@babel/plugin-transform-nullish-coalescing-operator'), { loose: true }], [require('@babel/plugin-transform-logical-assignment-operators'), { loose: true }], + // Keep this after transform-destructuring to avoid the loose-mode computed exclusion bug. + // See: https://github.com/babel/babel/pull/18197 + [require('@babel/plugin-transform-object-rest-spread'), { loose: true, useBuiltIns: true }], ] as PluginItem[], }; }; diff --git a/packages/expo-app-intents/.gitignore b/packages/expo-app-intents/.gitignore new file mode 100644 index 00000000000000..d95b2cd0947641 --- /dev/null +++ b/packages/expo-app-intents/.gitignore @@ -0,0 +1,2 @@ +# Written next to the tests by UserDefaults(suiteName: #file) during native test runs. +ios/Tests/*.swift.plist diff --git a/packages/expo-app-intents/.npmignore b/packages/expo-app-intents/.npmignore new file mode 100644 index 00000000000000..e0cb9856756941 --- /dev/null +++ b/packages/expo-app-intents/.npmignore @@ -0,0 +1,22 @@ +# @generated by expo-module-scripts + +# Exclude all top-level hidden directories by convention +/.*/ + +# Exclude tarballs generated by `npm pack` +/*.tgz + +__mocks__ +__tests__ +__rsc_tests__ + +/e2e +/e2e-cli +/babel.config.js +/oxlint.config.mjs +/android/src/androidTest/ +/android/src/test/ +/ios/Tests + +CLAUDE.md +CONTRIBUTING.md diff --git a/packages/expo-app-intents/CHANGELOG.md b/packages/expo-app-intents/CHANGELOG.md new file mode 100644 index 00000000000000..3608627b151793 --- /dev/null +++ b/packages/expo-app-intents/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +## Unpublished + +### 🛠 Breaking changes + +### 🎉 New features + +- Initial release. ([#47207](https://github.com/expo/expo/pull/47207) by [@behenate](https://github.com/behenate)) + +### 🐛 Bug fixes + +### 💡 Others diff --git a/packages/expo-app-intents/README.md b/packages/expo-app-intents/README.md new file mode 100644 index 00000000000000..f79f080ee1a05e --- /dev/null +++ b/packages/expo-app-intents/README.md @@ -0,0 +1,12 @@ +# expo-app-intents + +Expose Apple App Intents (Siri, Shortcuts, Spotlight, Apple Intelligence) from Expo apps. + +App Intent types must be compiled into the iOS app target. Apple's build-time metadata extraction does not see code in static pods. This package pairs a runtime pod (JS bridge, invocation queue, entity storage) with app-target Swift that you own, placed in an `app-intents/` directory via [Expo Inline Modules](https://docs.expo.dev/modules/inline-modules-tutorial/). + +## Limitations + +- Shortcut phrases are compiled at build time and cannot be created from JavaScript at runtime. Only parameter values are dynamic. +- A single classic App Shortcut phrase can interpolate at most one non-array parameter. +- Maximum 10 App Shortcuts per app; every phrase must include `\(.applicationName)`. +- iOS 16.4+, macOS 13.4+, and tvOS 16.4+. diff --git a/packages/expo-app-intents/app.plugin.js b/packages/expo-app-intents/app.plugin.js new file mode 100644 index 00000000000000..7be0245a4c3e8b --- /dev/null +++ b/packages/expo-app-intents/app.plugin.js @@ -0,0 +1 @@ +module.exports = require('./plugin/build/withAppIntents'); diff --git a/packages/expo-app-intents/babel.config.js b/packages/expo-app-intents/babel.config.js new file mode 100644 index 00000000000000..9d89e131194f49 --- /dev/null +++ b/packages/expo-app-intents/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function (api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/packages/expo-app-intents/expo-module.config.json b/packages/expo-app-intents/expo-module.config.json new file mode 100644 index 00000000000000..585075e73e3e5d --- /dev/null +++ b/packages/expo-app-intents/expo-module.config.json @@ -0,0 +1,6 @@ +{ + "platforms": ["apple"], + "apple": { + "modules": ["ExpoAppIntentsModule"] + } +} diff --git a/packages/expo-app-intents/ios/AppIntentDispatcher.swift b/packages/expo-app-intents/ios/AppIntentDispatcher.swift new file mode 100644 index 00000000000000..414333faddb855 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentDispatcher.swift @@ -0,0 +1,104 @@ +import ExpoModulesCore +import Foundation + +/// Ownership token for a single `AppIntentDispatcher.invocationEvents(for:)` subscription. +/// +/// Cancelling the read task alone still lets an old module receive invocations after a JavaScript +/// reload replaces it, so the token prevents it from subscribing at all. +internal final class AppIntentEventSubscription: @unchecked Sendable { + private let isInvalidated = Mutex(false) + + internal var isValid: Bool { + return isInvalidated.withLock { !$0 } + } + + internal func invalidate() { + isInvalidated.withLock { $0 = true } + } +} + +/// The bridge between app-target App Intent code and the Expo runtime. +/// `AppIntent.perform()` implementations call `await AppIntentDispatcher.shared.dispatch(...)`. +/// The dispatcher persists the invocation first, then notifies JS if it is alive. The intents can +/// be queried and handled from the JS side. +public actor AppIntentDispatcher { + public static let shared = AppIntentDispatcher() + + private let store: AppIntentInvocationStore + private var eventContinuations: [Int: AsyncStream.Continuation] = [:] + private var nextSubscriptionKey = 0 + + /// Set by the app-target `AppIntentsSetup` inline module. Must call + /// `AppShortcuts.updateAppShortcutParameters()` on the app's concrete + /// `AppShortcutsProvider` because the pod cannot reference that type. + private var shortcutsRefreshHandler: (@Sendable () async -> Void)? + + internal init(store: sending AppIntentInvocationStore = AppIntentInvocationStore()) { + self.store = store + } + + /// Returns a stream of the invocations dispatched while JavaScript is running. + /// Every subscription gets its own live stream, and the token has no default so a module replaced by + /// a JavaScript reload cannot subscribe. + internal func invocationEvents( + for subscription: AppIntentEventSubscription + ) -> AsyncStream { + guard subscription.isValid, !Task.isCancelled else { + return AsyncStream { continuation in + continuation.finish() + } + } + + nextSubscriptionKey += 1 + let key = nextSubscriptionKey + return AsyncStream { continuation in + eventContinuations[key] = continuation + continuation.onTermination = { _ in + Task { + await self.removeEventContinuation(key: key) + } + } + } + } + + private func removeEventContinuation(key: Int) { + eventContinuations.removeValue(forKey: key) + } + + @discardableResult + public func dispatch(name: String, params: AppIntentParams = [:]) -> String { + let invocation = AppIntentInvocation(name: name, params: params) + store.append(invocation) + for continuation in eventContinuations.values { + continuation.yield(invocation) + } + return invocation.id + } + + // Both of these propagate a storage failure so `ExpoAppIntentsModule` can reject the promise the + // caller is waiting on. A developer sees a rejected promise; the global `log` only reaches OSLog. + internal func pendingInvocations() throws -> [AppIntentInvocation] { + return try store.pending() + } + + internal func removePendingInvocation(id: String) throws { + try store.remove(id: id) + } + + internal func clearPendingInvocations() { + store.clear() + } + + public func setShortcutsRefreshHandler(_ handler: (@Sendable () async -> Void)?) { + shortcutsRefreshHandler = handler + } + + @discardableResult + internal func requestShortcutsRefresh() async -> Bool { + guard let handler = shortcutsRefreshHandler else { + return false + } + await handler() + return true + } +} diff --git a/packages/expo-app-intents/ios/AppIntentEntityStore.swift b/packages/expo-app-intents/ios/AppIntentEntityStore.swift new file mode 100644 index 00000000000000..acf2990770eb76 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentEntityStore.swift @@ -0,0 +1,174 @@ +import ExpoModulesCore +import Foundation + +/// An entity exposed to App Intents parameter queries. JS populates catalogs with +/// `setEntityCatalogAsync`; app-target `EntityQuery` implementations read them through +/// `AppIntentEntityStore.shared`. +/// +/// Catalogs are stored in UserDefaults, so they should stay compact. Apps with large +/// datasets should publish only the subset needed for Siri and Shortcuts resolution. +@Record +public struct AppIntentEntityRecord: Codable, Sendable { + var id: String + var title: String + var subtitle: String? + var synonyms: [String] = [] + + public init(id: String, title: String, subtitle: String? = nil) { + self.init(id: id, title: title, subtitle: subtitle, synonyms: []) + } +} + +public actor AppIntentEntityStore { + public static let shared = AppIntentEntityStore() + internal static let userDefaultsSuiteName = "dev.expo.appintents" + + private let suiteName: String + private var defaults: UserDefaults? + + internal init(userDefaultsSuiteName: String = AppIntentEntityStore.userDefaultsSuiteName) { + suiteName = userDefaultsSuiteName + } + + private func requireDefaults() throws -> UserDefaults { + if let defaults { + return defaults + } + + guard let defaults = UserDefaults(suiteName: suiteName) else { + throw AppIntentEntityStoreUnavailableException(suiteName) + } + + self.defaults = defaults + return defaults + } + + private func storageKey(kind: String) -> String { + return "dev.expo.appintents.entities.\(kind)" + } + + private func isBlank(_ value: String) -> Bool { + return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Returns the stored catalog of the given kind, throwing when the stored blob cannot be decoded. + /// Nothing is set aside the way `AppIntentInvocationStore.pending()` sets a corrupt queue aside. A + /// catalog is owned by JavaScript rather than accumulated natively, so the next `setCatalog` for the + /// kind replaces the unreadable blob and nothing is lost by leaving it in place until then. + public func entities(ofKind kind: String) throws -> [AppIntentEntityRecord] { + let defaults = try requireDefaults() + guard let data = defaults.data(forKey: storageKey(kind: kind)) else { + return [] + } + + do { + return try JSONDecoder().decode([AppIntentEntityRecord].self, from: data) + } catch { + let message = """ + expo-app-intents could not read the '\(kind)' entity catalog, so Siri and Shortcuts have no \ + values to offer or resolve for it. The stored data is not valid JSON for this version of \ + the module, which usually means a different version wrote it. Call \ + setEntityCatalogAsync('\(kind)', ...) to replace it. Decoding error: \(error.localizedDescription) + """ + log.error(message) + throw AppIntentEntityCatalogDecodingException(message) + } + } + + public func entities( + ofKind kind: String, + matching identifiers: [String] + ) throws -> [AppIntentEntityRecord] { + let identifierSet = Set(identifiers) + return try entities(ofKind: kind).filter { identifierSet.contains($0.id) } + } + + /// Replaces the catalog of the given kind, throwing instead of leaving the previous one in place + /// without saying so. + internal func setCatalog(kind: String, entities: [AppIntentEntityRecord]) throws { + // The kind names the catalog that app-target `EntityQuery` implementations read, so a blank + // kind stores a catalog no query ever asks for. + if isBlank(kind) { + throw AppIntentEntityCatalogKindException( + """ + expo-app-intents rejected an entity catalog because its kind is empty. Call \ + setEntityCatalogAsync with the same non-empty kind your EntityQuery uses. + """ + ) + } + + // An entity without an identifier can never be resolved or matched, and an entity without a + // title has nothing for Siri to match speech against. Required `@Record` properties reject a + // missing key, but not one explicitly set to an empty (or whitespace-only) string. + if let invalid = entities.first(where: { isBlank($0.id) || isBlank($0.title) }) { + throw AppIntentEntityInvalidFieldException( + """ + expo-app-intents rejected the '\(kind)' entity catalog because an entity has an empty \ + \(isBlank(invalid.id) ? "id" : "title"). Give every entity a non-empty 'id' and 'title', \ + then call 'setEntityCatalogAsync' again. + """ + ) + } + + // Two entities with one id cannot both be resolved, so the id no longer names one entity. + var seenIds = Set() + for entity in entities where !seenIds.insert(entity.id).inserted { + throw AppIntentEntityDuplicateIdException( + """ + expo-app-intents rejected the '\(kind)' entity catalog because more than one entity has \ + the id '\(entity.id)'. Give every entity a unique 'id', then call setEntityCatalogAsync again. + """ + ) + } + + let defaults = try requireDefaults() + do { + let data = try JSONEncoder().encode(entities) + defaults.set(data, forKey: storageKey(kind: kind)) + } catch { + // Every field of `AppIntentEntityRecord` is a string, so this should never happen. + throw AppIntentEntityCatalogEncodingException( + "expo-app-intents could not save the '\(kind)' entity catalog. Encoding error: \(error.localizedDescription)" + ) + } + } +} + +internal final class AppIntentEntityStoreUnavailableException: GenericException, @unchecked Sendable { + override var reason: String { + return """ + expo-app-intents could not access the '\(param)' UserDefaults suite, so entity catalogs cannot \ + be read or written. Try again later. + """ + } +} + +internal final class AppIntentEntityCatalogDecodingException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityCatalogKindException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityInvalidFieldException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityDuplicateIdException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +internal final class AppIntentEntityCatalogEncodingException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} diff --git a/packages/expo-app-intents/ios/AppIntentInvocation.swift b/packages/expo-app-intents/ios/AppIntentInvocation.swift new file mode 100644 index 00000000000000..d6369bd2b85b08 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentInvocation.swift @@ -0,0 +1,34 @@ +import ExpoModulesCore +import Foundation + +/// A single recorded App Intent invocation, persisted until JS removes it +/// with `removePendingInvocationAsync`. Delivery to JS is at-least-once. +public struct AppIntentInvocation: Codable, Sendable { + public let id: String + public let name: String + /// Intent-specific values. The top-level invocation schema is fixed; put custom + /// data for each intent here. + public let params: AppIntentParams + public let createdAt: Double + + public init(name: String, params: AppIntentParams) { + self.id = UUID().uuidString + self.name = name + // Params are made JSON-representable here, so the same values reach the persisted queue and + // the live event, and so no invocation can be lost to an encoding failure later on. + self.params = params.mapValues { $0.jsonSafe() } + self.createdAt = Date().timeIntervalSince1970 * 1000 + } + + func toDict() -> [String: Any] { + // swift-format requires a trailing comma in multiline collection literals. + // swiftlint:disable trailing_comma + return [ + "id": id, + "name": name, + "params": params.mapValues(\.foundationValue), + "createdAt": createdAt, + ] + // swiftlint:enable trailing_comma + } +} diff --git a/packages/expo-app-intents/ios/AppIntentInvocationStore.swift b/packages/expo-app-intents/ios/AppIntentInvocationStore.swift new file mode 100644 index 00000000000000..14b2ce2e83bab1 --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentInvocationStore.swift @@ -0,0 +1,116 @@ +import ExpoModulesCore +import Foundation + +/// Thrown when the persisted invocation queue cannot be read or written. +internal final class AppIntentQueueException: GenericException, @unchecked Sendable { + override var reason: String { + return param + } +} + +/// UserDefaults-backed persistence for intent invocations. +internal final class AppIntentInvocationStore { + /// The value has been chosen arbitrarily, but is comfortably above the number of invocations a user can trigger + /// while JavaScript is cold, which is what the queue exists to hold. + internal static let maxPendingInvocations = 100 + private static let pendingKey = "invocations.pending" + /// Where an undecodable queue is set aside, so a bug report can still recover the raw bytes. + private static let corruptedPendingKey = "invocations.pending.corrupted" + private let defaults: UserDefaults + /// A saturated queue drops one invocation on every append, so the log would repeat the same + /// message on every dispatch; once per launch is enough to point at the fix. + private var didLogDroppedInvocations = false + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + } + + init?(userDefaultsSuiteName: String) { + guard let defaults = UserDefaults(suiteName: userDefaultsSuiteName) else { + return nil + } + self.defaults = defaults + } + + private func storageKey(_ key: String) -> String { + return "dev.expo.appintents.\(key)" + } + + /// Returns the persisted queue, throwing when the stored blob cannot be decoded. + func pending() throws -> [AppIntentInvocation] { + guard let data = defaults.data(forKey: storageKey(Self.pendingKey)) else { + return [] + } + + do { + return try JSONDecoder().decode([AppIntentInvocation].self, from: data) + } catch { + // Set the bytes aside before anything can overwrite them. + defaults.set(data, forKey: storageKey(Self.corruptedPendingKey)) + defaults.removeObject(forKey: storageKey(Self.pendingKey)) + + let message = + "expo-app-intents could not read the pending App Intent invocation queue, so any invocation " + + "that was waiting for JavaScript is not delivered. The stored data is not valid JSON for " + + "this version of the module, which usually means it was written by a different one. The " + + "queue starts empty from now on; the unreadable data is kept under " + + "'\(storageKey(Self.corruptedPendingKey))' in UserDefaults. Trigger the intent again, and " + + "please report this at https://github.com/expo/expo/issues with that data. Decoding " + + "error: \(error.localizedDescription)" + log.error(message) + throw AppIntentQueueException(message) + } + } + + /// Appends an invocation, keeping it even when the stored queue was unreadable. + /// + /// `dispatch` runs while JavaScript may still be cold, so there is nowhere to report a failure. + /// `pending()` has already set a corrupt queue aside and logged it by this point, so starting a + /// fresh queue is safe here. + func append(_ invocation: AppIntentInvocation) { + var invocations = (try? pending()) ?? [] + invocations.append(invocation) + + if invocations.count > Self.maxPendingInvocations { + let droppedCount = invocations.count - Self.maxPendingInvocations + invocations.removeFirst(droppedCount) + if !didLogDroppedInvocations { + didLogDroppedInvocations = true + log.error( + "expo-app-intents dropped \(droppedCount) pending App Intent invocation(s) to keep the " + + "queue at its limit of \(Self.maxPendingInvocations), and keeps dropping the oldest " + + "one on every dispatch until the queue is drained (this is only logged once). Mount " + + "useAppIntents() once near the root of your app, and remove each invocation once you " + + "have handled it. The newest \(Self.maxPendingInvocations) invocations are kept." + ) + } + } + + do { + try persist(invocations) + } catch { + log.error( + "expo-app-intents could not save the pending App Intent invocation queue, so the invocation " + + "that just arrived is lost and never reaches JavaScript. Encoding error: \(error.localizedDescription)" + ) + } + } + + func remove(id: String) throws { + try persist(try pending().filter { $0.id != id }) + } + + /// Drops the whole queue, including a blob that was set aside as corrupt. + func clear() { + defaults.removeObject(forKey: storageKey(Self.pendingKey)) + defaults.removeObject(forKey: storageKey(Self.corruptedPendingKey)) + } + + /// Writes the queue, leaving the stored queue untouched when it cannot be encoded. + /// `AppIntentInvocation` makes its params JSON-representable, so this should never fail; if it + /// ever does, keeping the previous queue is better than replacing it with nothing. + private func persist(_ invocations: [AppIntentInvocation]) throws { + let data = try JSONEncoder().encode(invocations) + defaults.set(data, forKey: storageKey(Self.pendingKey)) + } +} diff --git a/packages/expo-app-intents/ios/AppIntentValue.swift b/packages/expo-app-intents/ios/AppIntentValue.swift new file mode 100644 index 00000000000000..8ba2f61e635b9a --- /dev/null +++ b/packages/expo-app-intents/ios/AppIntentValue.swift @@ -0,0 +1,147 @@ +import ExpoModulesCore +import Foundation + +public typealias AppIntentParams = [String: AppIntentValue] + +// swift-format moves the brace to a new line for wrapped declarations. +// swiftlint:disable opening_brace +/// A Codable, Sendable JSON value used to persist App Intent params while JS is cold. +public enum AppIntentValue: Codable, Equatable, Sendable, ExpressibleByStringLiteral, + ExpressibleByIntegerLiteral, ExpressibleByFloatLiteral, ExpressibleByBooleanLiteral, + ExpressibleByArrayLiteral, ExpressibleByDictionaryLiteral, ExpressibleByNilLiteral +{ + // swiftlint:enable opening_brace + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case array([AppIntentValue]) + case object([String: AppIntentValue]) + case null + + public init(_ value: String) { + self = .string(value) + } + + public init(_ value: Int) { + self = .int(value) + } + + public init(_ value: Double) { + self = .double(value) + } + + public init(_ value: Bool) { + self = .bool(value) + } + + /// Returns an equivalent value that JSON can represent. + func jsonSafe() -> AppIntentValue { + switch self { + case .double(let value) where !value.isFinite: + log.warn( + "expo-app-intents replaced a non-finite number (\(value)) in App Intent params with null, " + + "because JSON cannot represent NaN or infinity and the invocation would otherwise be " + + "dropped. Pass a finite number, or a string if the exact value matters." + ) + return .null + case .array(let values): + return .array(values.map { $0.jsonSafe() }) + case .object(let values): + return .object(values.mapValues { $0.jsonSafe() }) + default: + return self + } + } + + var foundationValue: Any { + switch self { + case .string(let value): + return value + case .int(let value): + return value + case .double(let value): + return value + case .bool(let value): + return value + case .array(let value): + return value.map(\.foundationValue) + case .object(let value): + return value.mapValues(\.foundationValue) + case .null: + return NSNull() + } + } + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([AppIntentValue].self) { + self = .array(value) + } else if let value = try? container.decode([String: AppIntentValue].self) { + self = .object(value) + } else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unsupported App Intent payload value" + ) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + public init(stringLiteral value: String) { + self = .string(value) + } + + public init(integerLiteral value: Int) { + self = .int(value) + } + + public init(floatLiteral value: Double) { + self = .double(value) + } + + public init(booleanLiteral value: Bool) { + self = .bool(value) + } + + public init(arrayLiteral elements: AppIntentValue...) { + self = .array(elements) + } + + public init(dictionaryLiteral elements: (String, AppIntentValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } + + public init(nilLiteral: ()) { + self = .null + } +} diff --git a/packages/expo-app-intents/ios/ExpoAppIntents.podspec b/packages/expo-app-intents/ios/ExpoAppIntents.podspec new file mode 100644 index 00000000000000..bec74bbcce0021 --- /dev/null +++ b/packages/expo-app-intents/ios/ExpoAppIntents.podspec @@ -0,0 +1,35 @@ +require 'json' + +package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json'))) + +Pod::Spec.new do |s| + s.name = 'ExpoAppIntents' + s.version = package['version'] + s.summary = package['description'] + s.description = package['description'] + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { + :ios => '16.4', + :osx => '13.4', + :tvos => '16.4' + } + s.swift_version = '6.0' + s.source = { git: 'https://github.com/expo/expo.git' } + s.static_framework = true + + s.dependency 'ExpoModulesCore' + + s.source_files = "**/*.{h,m,swift}" + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + s.exclude_files = 'Tests/' + s.test_spec 'Tests' do |test_spec| + test_spec.dependency 'ExpoModulesTestCore' + test_spec.source_files = 'Tests/**/*.{m,swift}' + end +end diff --git a/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift b/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift new file mode 100644 index 00000000000000..da04e20305b62b --- /dev/null +++ b/packages/expo-app-intents/ios/ExpoAppIntentsModule.swift @@ -0,0 +1,85 @@ +import ExpoModulesCore + +internal final class ShortcutsRefreshUnavailableException: Exception, @unchecked Sendable { + override var reason: String { + "App Shortcuts could not be refreshed because no refresh handler is registered. " + + "The app target must contain an 'AppIntentsSetup' inline module that sets " + + "AppIntentDispatcher.shared.setShortcutsRefreshHandler(...). Run `npx expo-app-intents init` " + + "to generate it, or add it manually as described in the expo-app-intents documentation." + } +} + +public final class ExpoAppIntentsModule: Module, @unchecked Sendable { + private var invocationEventsTask: Task? + private var invocationEventsSubscription: AppIntentEventSubscription? + + public func definition() -> ModuleDefinition { + Name("ExpoAppIntents") + + Events("onIntent") + + OnCreate { [weak self] in + guard let self else { + return + } + // The token is created here, synchronously, so `OnDestroy` can invalidate it before the task + // below ever reaches the dispatcher. + let subscription = AppIntentEventSubscription() + self.invocationEventsSubscription = subscription + self.invocationEventsTask = Task { [weak self] in + for await invocation in await AppIntentDispatcher.shared.invocationEvents(for: subscription) { + guard !Task.isCancelled else { + break + } + self?.sendIntentEvent(invocation) + } + } + } + + OnDestroy { + invocationEventsSubscription?.invalidate() + invocationEventsSubscription = nil + invocationEventsTask?.cancel() + invocationEventsTask = nil + } + + AsyncFunction("getPendingInvocationsAsync") { () async throws -> [[String: Any]] in + return try await AppIntentDispatcher.shared.pendingInvocations().map { $0.toDict() } + } + + AsyncFunction("removePendingInvocationAsync") { (id: String) async throws in + try await AppIntentDispatcher.shared.removePendingInvocation(id: id) + } + + AsyncFunction("clearPendingInvocationsAsync") { () async in + await AppIntentDispatcher.shared.clearPendingInvocations() + } + + AsyncFunction("setEntityCatalogAsync") { (kind: String, entities: [AppIntentEntityRecord]) async throws in + try await AppIntentEntityStore.shared.setCatalog(kind: kind, entities: entities) + // Best-effort, and deliberately not propagated: the catalog is already stored by this point, + // so failing the call would report a write that did happen as an error. An app with no App + // Shortcut phrases has no `AppShortcutsProvider` to register a refresh handler, and no + // shortcut parameters to re-train either. + await AppIntentDispatcher.shared.requestShortcutsRefresh() + } + + AsyncFunction("getEntityCatalogAsync") { (kind: String) async throws -> [AppIntentEntityRecord] in + return try await AppIntentEntityStore.shared.entities(ofKind: kind) + } + + AsyncFunction("refreshShortcutsAsync") { () async throws in + try await self.refreshShortcuts() + } + } + + private func refreshShortcuts() async throws { + if !(await AppIntentDispatcher.shared.requestShortcutsRefresh()) { + throw ShortcutsRefreshUnavailableException() + } + } + + private func sendIntentEvent(_ invocation: AppIntentInvocation) { + sendEvent("onIntent", invocation.toDict()) + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift new file mode 100644 index 00000000000000..9e01fc5fae9129 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentDispatcherTests.swift @@ -0,0 +1,232 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +/// Collects what a stream consumer saw, so assertions never depend on task scheduling order. +private actor EventRecorder { + private(set) var names: [String] = [] + private(set) var didFinish = false + + func record(_ name: String) { + names.append(name) + } + + func markFinished() { + didFinish = true + } +} + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentDispatcher", .serialized) +struct AppIntentDispatcherTests { + private let dispatcher: AppIntentDispatcher + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + dispatcher = AppIntentDispatcher( + store: try #require(AppIntentInvocationStore(userDefaultsSuiteName: #file)) + ) + } + + @Test + func `dispatch persists before any listener`() async throws { + let id = await dispatcher.dispatch(name: "increaseCounter", params: ["by": 1]) + + let pending = try await dispatcher.pendingInvocations() + #expect(pending.map(\.id) == [id]) + #expect(pending[0].name == "increaseCounter") + } + + @Test + func `dispatch yields an invocation event`() async throws { + let recorder = EventRecorder() + let consumer = await subscribe(into: recorder) + + await dispatcher.dispatch(name: "startHike", params: ["trailId": "t1"]) + + let delivered = await waitUntil { await recorder.names == ["startHike"] } + #expect(delivered, "expected the live listener to receive 'startHike'") + let pending = try await dispatcher.pendingInvocations() + #expect(pending.count == 1) + consumer.cancel() + } + + @Test + func `terminating an old event stream does not clear a newer listener`() async { + let oldRecorder = EventRecorder() + let oldConsumer = await subscribe(into: oldRecorder) + + let newRecorder = EventRecorder() + let newConsumer = await subscribe(into: newRecorder) + + oldConsumer.cancel() + _ = await waitUntil { await oldRecorder.didFinish } + + await dispatcher.dispatch(name: "afterRefresh", params: [:]) + + let delivered = await waitUntil { await newRecorder.names == ["afterRefresh"] } + #expect(delivered, "expected the newer listener to receive 'afterRefresh'") + newConsumer.cancel() + } + + /// Two `AppContext`s genuinely coexist, for example while a JavaScript reload settles. A second + /// subscription must not silence the first: with a single continuation the older context was deaf + /// for the rest of its life. + @Test + func `every subscriber receives every invocation`() async { + let firstRecorder = EventRecorder() + let firstConsumer = await subscribe(into: firstRecorder) + let secondRecorder = EventRecorder() + let secondConsumer = await subscribe(into: secondRecorder) + + await dispatcher.dispatch(name: "startHike", params: [:]) + + let firstGotIt = await waitUntil { await firstRecorder.names == ["startHike"] } + let secondGotIt = await waitUntil { await secondRecorder.names == ["startHike"] } + #expect(firstGotIt, "expected the older subscriber to keep receiving") + #expect(secondGotIt, "expected the newer subscriber to receive") + let firstFinished = await firstRecorder.didFinish + #expect(!firstFinished, "a second subscription must not finish the first stream") + + firstConsumer.cancel() + secondConsumer.cancel() + } + + /// Destroying the newest subscriber must not leave the app deaf. With a single continuation, the + /// remaining context had no way to register again and nothing listened to `onIntent` any more. + @Test + func `the remaining subscriber still receives after the newest is destroyed`() async { + let survivingRecorder = EventRecorder() + let survivingConsumer = await subscribe(into: survivingRecorder) + + let doomedRecorder = EventRecorder() + let doomedConsumer = await subscribe(into: doomedRecorder) + doomedConsumer.cancel() + _ = await waitUntil { await doomedRecorder.didFinish } + + await dispatcher.dispatch(name: "afterTeardown", params: [:]) + + let delivered = await waitUntil { await survivingRecorder.names == ["afterTeardown"] } + #expect(delivered, "expected the surviving subscriber to still receive invocations") + survivingConsumer.cancel() + } + + /// A JavaScript reload briefly keeps two modules alive. The old module's `OnDestroy` cancels its + /// event task, but a cancelled task still runs its body, so it can still reach the dispatcher after + /// the new module registered its stream. Registering then would immediately terminate again and + /// leave nothing listening, and `onIntent` would never fire again. + @Test + func `a cancelled task cannot replace the live listener`() async { + let liveRecorder = EventRecorder() + let liveConsumer = await subscribe(into: liveRecorder) + + let staleTask = Task { [dispatcher] in + let subscription = AppIntentEventSubscription() + for await _ in await dispatcher.invocationEvents(for: subscription) {} + } + staleTask.cancel() + _ = await waitUntil { staleTask.isCancelled } + try? await Task.sleep(nanoseconds: 100_000_000) + + await dispatcher.dispatch(name: "afterReload", params: [:]) + + let delivered = await waitUntil { await liveRecorder.names == ["afterReload"] } + #expect(delivered, "expected the live listener to keep receiving after a stale task ran") + liveConsumer.cancel() + } + + /// Same race, resolved through the token the destroyed module invalidated in `OnDestroy`. + @Test + func `an invalidated subscription cannot replace the live listener`() async { + let liveRecorder = EventRecorder() + let liveConsumer = await subscribe(into: liveRecorder) + + let staleSubscription = AppIntentEventSubscription() + staleSubscription.invalidate() + let staleRecorder = EventRecorder() + let staleConsumer = consume( + await dispatcher.invocationEvents(for: staleSubscription), + into: staleRecorder + ) + _ = await waitUntil { await staleRecorder.didFinish } + + await dispatcher.dispatch(name: "afterReload", params: [:]) + + let delivered = await waitUntil { await liveRecorder.names == ["afterReload"] } + let staleNames = await staleRecorder.names + #expect(delivered, "expected the live listener to keep receiving after a stale token ran") + #expect(staleNames.isEmpty) + liveConsumer.cancel() + staleConsumer.cancel() + } + + @Test + func `removes a pending invocation and clears the queue`() async throws { + let id = await dispatcher.dispatch(name: "a", params: [:]) + await dispatcher.dispatch(name: "b", params: [:]) + + try await dispatcher.removePendingInvocation(id: id) + let afterRemove = try await dispatcher.pendingInvocations() + #expect(afterRemove.count == 1) + + await dispatcher.clearPendingInvocations() + let afterClear = try await dispatcher.pendingInvocations() + #expect(afterClear.isEmpty) + } + + @Test + func `requestShortcutsRefresh invokes the registered handler`() async { + let handlerCalled = EventRecorder() + await dispatcher.setShortcutsRefreshHandler { + await handlerCalled.record("refresh") + } + + let refreshed = await dispatcher.requestShortcutsRefresh() + let names = await handlerCalled.names + #expect(refreshed) + #expect(names == ["refresh"]) + } + + @Test + func `requestShortcutsRefresh without a handler returns false`() async { + let refreshed = await dispatcher.requestShortcutsRefresh() + #expect(!refreshed) + } + + /// Subscribes with a token of its own, because `invocationEvents(for:)` has no default one. + private func subscribe(into recorder: EventRecorder) async -> Task { + let stream = await dispatcher.invocationEvents(for: AppIntentEventSubscription()) + return consume(stream, into: recorder) + } + + private func consume( + _ stream: AsyncStream, + into recorder: EventRecorder + ) -> Task { + return Task { + for await invocation in stream { + await recorder.record(invocation.name) + } + await recorder.markFinished() + } + } + + /// Polls until `condition` holds. Awaiting a task that never completes would hang the whole test + /// run, so every wait here is bounded and reported as a failed assertion instead. + private func waitUntil( + timeout: UInt64 = 2_000_000_000, + _ condition: @Sendable () async -> Bool + ) async -> Bool { + let deadline = DispatchTime.now().uptimeNanoseconds + timeout + while DispatchTime.now().uptimeNanoseconds < deadline { + if await condition() { + return true + } + try? await Task.sleep(nanoseconds: 10_000_000) + } + return await condition() + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift new file mode 100644 index 00000000000000..c47f806eb21259 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentEntityStoreTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents +@testable import ExpoModulesCore + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentEntityStore", .serialized) +struct AppIntentEntityStoreTests { + private static let trailStorageKey = "dev.expo.appintents.entities.trail" + + private let store: AppIntentEntityStore + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + store = AppIntentEntityStore(userDefaultsSuiteName: #file) + } + + @Test + func `sets and reads a catalog`() async throws { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "Eagle Peak", subtitle: "5 km", synonyms: ["eagle"]), + AppIntentEntityRecord(id: "t2", title: "Lake Loop", subtitle: nil, synonyms: []), + ] + ) + + let all = try await store.entities(ofKind: "trail") + #expect(all.map(\.id) == ["t1", "t2"]) + #expect(all[0].title == "Eagle Peak") + #expect(all[0].subtitle == "5 km") + #expect(all[0].synonyms == ["eagle"]) + #expect(all[1].subtitle == nil) + } + + @Test + func `filters entities by matching identifiers`() async throws { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "A", subtitle: nil, synonyms: []), + AppIntentEntityRecord(id: "t2", title: "B", subtitle: nil, synonyms: []), + ] + ) + + let matching = try await store.entities(ofKind: "trail", matching: ["t2"]) + #expect(matching.map(\.id) == ["t2"]) + } + + @Test + func `an unknown kind is empty`() async throws { + let entities = try await store.entities(ofKind: "missing") + #expect(entities.isEmpty) + } + + /// A `try?` here used to hand back an empty catalog, which is exactly what an unpublished kind looks + /// like: Siri would offer nothing and the developer would never learn why. + @Test + func `entities reports an undecodable catalog instead of returning it empty`() async { + defaults.set(Data("not json at all".utf8), forKey: Self.trailStorageKey) + + await #expect(throws: (any Error).self) { + _ = try await store.entities(ofKind: "trail") + } + } + + /// Publishing the kind again has to recover, because the catalog is owned by JavaScript. + @Test + func `setCatalog replaces an undecodable catalog`() async throws { + defaults.set(Data("not json at all".utf8), forKey: Self.trailStorageKey) + + try await store.setCatalog( + kind: "trail", + entities: [AppIntentEntityRecord(id: "t1", title: "Eagle Peak")] + ) + + let readBack = try await store.entities(ofKind: "trail") + #expect(readBack.map(\.id) == ["t1"]) + } + + /// A non-optional `@Record` property without a default is required at the JavaScript boundary. + @Test + func `record requires an id and a title`() throws { + let appContext = AppContext.create() + + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: [:], appContext: appContext) + } + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: ["id": "t1"], appContext: appContext) + } + #expect(throws: RecordPropertyRequiredException.self) { + try AppIntentEntityRecord.from(dictionary: ["title": "A"], appContext: appContext) + } + _ = try AppIntentEntityRecord.from( + dictionary: ["id": "t1", "title": "A"], + appContext: appContext + ) + } + + /// Required record fields reject missing keys, but an explicit empty string is just as + /// unresolvable, and so is a string made only of whitespace. + @Test + func `setCatalog rejects an empty or blank identifier or title`() async throws { + for invalid in [ + AppIntentEntityRecord(id: "", title: "No id"), + AppIntentEntityRecord(id: " ", title: "Blank id"), + AppIntentEntityRecord(id: "t1", title: ""), + AppIntentEntityRecord(id: "t1", title: "\n"), + ] { + await #expect(throws: AppIntentEntityInvalidFieldException.self) { + try await store.setCatalog(kind: "trail", entities: [invalid]) + } + } + + let stored = try await store.entities(ofKind: "trail") + #expect(stored.isEmpty, "a rejected catalog must not be stored") + } + + /// The kind names the catalog that app-target `EntityQuery` implementations read, so a blank kind + /// stores a catalog no query ever finds. Entities get this validation; the kind needs it too. + @Test + func `setCatalog rejects a blank kind`() async throws { + for blankKind in ["", " ", "\n"] { + await #expect( + throws: AppIntentEntityCatalogKindException.self, + "expected setCatalog to reject the blank kind '\(blankKind)'" + ) { + try await store.setCatalog( + kind: blankKind, + entities: [AppIntentEntityRecord(id: "t1", title: "A")] + ) + } + } + } + + /// Two entities with one id cannot both be resolved, so the catalog is ambiguous as a whole. + @Test + func `setCatalog rejects duplicate identifiers`() async throws { + await #expect(throws: AppIntentEntityDuplicateIdException.self) { + try await store.setCatalog( + kind: "trail", + entities: [ + AppIntentEntityRecord(id: "t1", title: "Eagle Peak"), + AppIntentEntityRecord(id: "t1", title: "Lake Loop"), + ] + ) + } + + let stored = try await store.entities(ofKind: "trail") + #expect(stored.isEmpty, "a rejected catalog must not be stored") + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift new file mode 100644 index 00000000000000..72f451b0806da2 --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentInvocationStoreTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +/// Serialized because every test works in the same `#file`-named UserDefaults suite. +@Suite("AppIntentInvocationStore", .serialized) +struct AppIntentInvocationStoreTests { + private static let pendingStorageKey = "dev.expo.appintents.invocations.pending" + private static let corruptedStorageKey = "dev.expo.appintents.invocations.pending.corrupted" + + private let store: AppIntentInvocationStore + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: #file)) + defaults.removePersistentDomain(forName: #file) + store = AppIntentInvocationStore(defaults: defaults) + } + + @Test + func `appends and reads pending invocations`() throws { + let invocation = AppIntentInvocation(name: "startHike", params: ["trailId": "t1"]) + store.append(invocation) + + let pending = try store.pending() + #expect(pending.count == 1) + #expect(pending[0].id == invocation.id) + #expect(pending[0].name == "startHike") + #expect(pending[0].params["trailId"] == .string("t1")) + } + + @Test + func `removes an invocation by id`() throws { + let first = AppIntentInvocation(name: "a", params: [:]) + let second = AppIntentInvocation(name: "b", params: [:]) + store.append(first) + store.append(second) + + try store.remove(id: first.id) + + #expect(try store.pending().map(\.id) == [second.id]) + } + + @Test + func `clears the queue`() throws { + store.append(AppIntentInvocation(name: "a", params: [:])) + store.clear() + #expect(try store.pending().isEmpty) + } + + @Test + func `append keeps the queue when params cannot be represented in JSON`() throws { + store.append(AppIntentInvocation(name: "queued", params: ["ok": "yes"])) + store.append(AppIntentInvocation(name: "nonFinite", params: ["x": .double(Double.nan)])) + + let pending = try store.pending() + #expect(pending.map(\.name) == ["queued", "nonFinite"]) + #expect(pending.last?.params["x"] == .null) + } + + @Test + func `persists across instances`() throws { + store.append(AppIntentInvocation(name: "cold", params: [:])) + let secondInstance = AppIntentInvocationStore(defaults: defaults) + #expect(try secondInstance.pending().first?.name == "cold") + } + + /// A `try?` here used to hand back an empty queue, which the next write turned into the real stored + /// queue. The corruption has to be reported instead, and the bytes have to survive it. + @Test + func `pending reports a corrupt queue instead of returning it empty`() { + let corrupt = Data("not json at all".utf8) + defaults.set(corrupt, forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.pending() + } + #expect(defaults.data(forKey: Self.corruptedStorageKey) == corrupt) + } + + /// The next dispatch must still be recorded, and must not be what destroys the corrupt blob. + @Test + func `append after corruption keeps both the new invocation and the corrupt bytes`() throws { + let corrupt = Data("not json at all".utf8) + defaults.set(corrupt, forKey: Self.pendingStorageKey) + + store.append(AppIntentInvocation(name: "afterCorruption", params: [:])) + + #expect(try store.pending().map(\.name) == ["afterCorruption"]) + #expect(defaults.data(forKey: Self.corruptedStorageKey) == corrupt) + } + + /// Reporting once is enough: the queue recovers rather than failing on every later read. + @Test + func `pending recovers after reporting corruption`() throws { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.pending() + } + #expect(try store.pending().isEmpty) + } + + @Test + func `clear also drops the corrupt blob`() { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + #expect(throws: (any Error).self) { + try store.pending() + } + + store.clear() + + #expect(defaults.data(forKey: Self.corruptedStorageKey) == nil) + } + + @Test + func `remove reports a corrupt queue`() { + defaults.set(Data("not json at all".utf8), forKey: Self.pendingStorageKey) + + #expect(throws: (any Error).self) { + try store.remove(id: "whatever") + } + } + + /// Only JavaScript drains this queue, so an app that never dequeues - one whose handler throws, or + /// that never mounts `useAppIntents` - would grow it without bound. `UserDefaults` is read into + /// memory when the app launches, so that is a cost every later start pays. + @Test + func `append drops the oldest invocations once the queue is full`() throws { + let capacity = AppIntentInvocationStore.maxPendingInvocations + for index in 0...capacity { + store.append(AppIntentInvocation(name: "invocation\(index)", params: [:])) + } + + let pending = try store.pending() + #expect(pending.count == capacity, "the queue may not grow past its capacity") + // The newest invocation is the one the user just asked for, so the oldest is the one to lose. + #expect(pending.first?.name == "invocation1", "the oldest invocation is the one dropped") + #expect(pending.last?.name == "invocation\(capacity)", "the newest invocation is kept") + } + + /// A queue already over capacity - written by a build with a higher cap - has to come back down. + @Test + func `append trims a queue that is already over capacity`() throws { + let capacity = AppIntentInvocationStore.maxPendingInvocations + let oversized = (0..<(capacity + 10)).map { AppIntentInvocation(name: "old\($0)", params: [:]) } + defaults.set(try JSONEncoder().encode(oversized), forKey: Self.pendingStorageKey) + + store.append(AppIntentInvocation(name: "newest", params: [:])) + + let pending = try store.pending() + #expect(pending.count == capacity) + #expect(pending.last?.name == "newest") + } +} diff --git a/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift b/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift new file mode 100644 index 00000000000000..e1f14d3de316bc --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/AppIntentValueTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents + +@Suite("AppIntentValue") +struct AppIntentValueTests { + @Test + func `round-trips every case through Codable`() throws { + let value = AppIntentValue.object([ + "string": .string("s"), + "int": .int(3), + "double": .double(1.5), + "bool": .bool(true), + "null": .null, + "array": .array([.int(1), .string("two"), .bool(false)]), + ]) + + let data = try JSONEncoder().encode(value) + let decoded = try JSONDecoder().decode(AppIntentValue.self, from: data) + + #expect(decoded == value) + } + + @Test + func `jsonSafe replaces non-finite numbers with null`() { + #expect(AppIntentValue.double(Double.nan).jsonSafe() == .null) + #expect(AppIntentValue.double(Double.infinity).jsonSafe() == .null) + #expect(AppIntentValue.double(-Double.infinity).jsonSafe() == .null) + #expect(AppIntentValue.double(1.5).jsonSafe() == .double(1.5)) + } + + @Test + func `jsonSafe recurses into arrays and objects`() { + let value = AppIntentValue.object([ + "list": .array([.double(Double.infinity), .int(1)]), + "map": .object(["deep": .double(Double.nan)]), + ]) + + #expect( + value.jsonSafe() + == .object([ + "list": .array([.null, .int(1)]), + "map": .object(["deep": .null]), + ]) + ) + } + + @Test + func `invocation params are JSON-safe and match the live event payload`() throws { + let invocation = AppIntentInvocation(name: "nonFinite", params: ["x": .double(Double.nan)]) + + #expect(invocation.params["x"] == .null) + // The live event and the persisted invocation must carry the same value. + #expect((invocation.toDict()["params"] as? [String: Any])?["x"] is NSNull) + _ = try JSONEncoder().encode(invocation) + } +} diff --git a/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift b/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift new file mode 100644 index 00000000000000..a33f753059e67a --- /dev/null +++ b/packages/expo-app-intents/ios/Tests/ExpoAppIntentsModuleTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing + +@testable import ExpoAppIntents +@testable import ExpoModulesCore + +/// Covers the module's JavaScript-facing surface. These go through the runtime rather than calling the +/// actors directly, because what matters here is whether the JavaScript promise resolves or rejects. +@Suite("ExpoAppIntentsModule", .serialized) +@JavaScriptActor +struct ExpoAppIntentsModuleTests { + let appContext: AppContext + let runtime: ExpoRuntime + + init() throws { + appContext = AppContext.create() + runtime = try appContext.runtime + appContext.moduleRegistry.register( + holder: ModuleHolder( + appContext: appContext, + module: ExpoAppIntentsModule(appContext: appContext), + name: "ExpoAppIntents" + ) + ) + } + + /// A scaffold with no App Shortcut phrases has no `AppShortcutsProvider`, so nothing registers a + /// refresh handler. Publishing a catalog still has to succeed: there are no shortcut parameters to + /// re-train, and the catalog is already stored by the time the refresh is attempted. + @Test + func `publishing a catalog succeeds when no refresh handler is registered`() async throws { + let kind = "testDraftWithoutRefreshHandler" + let storageKey = "dev.expo.appintents.entities.\(kind)" + let defaults = try #require( + UserDefaults(suiteName: AppIntentEntityStore.userDefaultsSuiteName) + ) + // Cleared so the assertion below can only pass on what this test itself published, and cleared + // again on the way out so the published catalog does not outlive the test in the module's + // UserDefaults suite. + defaults.removeObject(forKey: storageKey) + defer { + defaults.removeObject(forKey: storageKey) + } + await AppIntentDispatcher.shared.setShortcutsRefreshHandler(nil) + + _ = try await runtime.evalAsync( + "expo.modules.ExpoAppIntents.setEntityCatalogAsync('\(kind)', [{ id: 'a', title: 'A' }])" + ) + + let stored = try await AppIntentEntityStore.shared.entities(ofKind: kind) + #expect(stored.map(\.id) == ["a"]) + } + + /// Asking for a refresh explicitly is different: there is nothing else the call could have + /// accomplished, so a missing handler has to surface rather than pass silently. + @Test + func `refreshing shortcuts fails when no refresh handler is registered`() async throws { + await AppIntentDispatcher.shared.setShortcutsRefreshHandler(nil) + + await #expect(throws: (any Error).self) { + _ = try await runtime.evalAsync("expo.modules.ExpoAppIntents.refreshShortcutsAsync()") + } + } +} diff --git a/packages/expo-app-intents/jest.config.js b/packages/expo-app-intents/jest.config.js new file mode 100644 index 00000000000000..3ff35e3ba996a3 --- /dev/null +++ b/packages/expo-app-intents/jest.config.js @@ -0,0 +1,4 @@ +// The config plugin has its own tests under `plugin/`, and the module preset roots collection at +// `src` — so without this they are collected by nothing and `passWithNoTests` keeps the run green. +// Running `plugin` as its own project means one `jest` invocation covers the whole package. +module.exports = require('expo-module-scripts/createCompositeJestPreset')(__dirname, ['plugin']); diff --git a/packages/expo-app-intents/oxlint.config.mjs b/packages/expo-app-intents/oxlint.config.mjs new file mode 100644 index 00000000000000..4a0fea02e3a3db --- /dev/null +++ b/packages/expo-app-intents/oxlint.config.mjs @@ -0,0 +1,2 @@ +// @generated by expo-module-scripts +export { default } from 'expo-module-scripts/oxlint.config.base'; diff --git a/packages/expo-app-intents/package.json b/packages/expo-app-intents/package.json new file mode 100644 index 00000000000000..3b193e72640777 --- /dev/null +++ b/packages/expo-app-intents/package.json @@ -0,0 +1,51 @@ +{ + "name": "expo-app-intents", + "version": "0.1.0", + "description": "Expose Apple App Intents (Siri, Shortcuts, Spotlight, Apple Intelligence) from Expo apps", + "main": "build/index.js", + "types": "build/index.d.ts", + "sideEffects": false, + "scripts": { + "build": "pnpm run build:lib && pnpm run build:plugin", + "build:lib": "expo-build src", + "build:plugin": "expo-build --base plugin cjs:src", + "clean": "expo-module clean", + "lint": "oxlint --config oxlint.config.mjs .", + "test": "jest", + "prepublishOnly": "expo-module prepublishOnly", + "depscheck": "expo-module depscheck", + "typecheck": "tsc -b tsconfig.all.json" + }, + "keywords": [ + "react-native", + "expo", + "expo-app-intents", + "siri", + "app-intents", + "shortcuts" + ], + "repository": { + "type": "git", + "url": "https://github.com/expo/expo.git", + "directory": "packages/expo-app-intents" + }, + "bugs": { + "url": "https://github.com/expo/expo/issues" + }, + "author": "650 Industries, Inc.", + "license": "MIT", + "homepage": "https://docs.expo.dev/versions/latest/sdk/app-intents", + "devDependencies": { + "@testing-library/react-native": "^13.3.0", + "@types/jest": "^29.2.1", + "@types/node": "^22.14.0", + "@types/react": "~19.2.0", + "expo": "workspace:*", + "expo-module-scripts": "workspace:*" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } +} diff --git a/packages/expo-app-intents/plugin/index.d.ts b/packages/expo-app-intents/plugin/index.d.ts new file mode 100644 index 00000000000000..99a73babced38a --- /dev/null +++ b/packages/expo-app-intents/plugin/index.d.ts @@ -0,0 +1 @@ +export { default } from './build'; diff --git a/packages/expo-app-intents/plugin/index.js b/packages/expo-app-intents/plugin/index.js new file mode 100644 index 00000000000000..a5c027389cd64c --- /dev/null +++ b/packages/expo-app-intents/plugin/index.js @@ -0,0 +1 @@ +module.exports = require('./build'); diff --git a/packages/expo-app-intents/plugin/jest.config.js b/packages/expo-app-intents/plugin/jest.config.js new file mode 100644 index 00000000000000..e539b3b187ee4f --- /dev/null +++ b/packages/expo-app-intents/plugin/jest.config.js @@ -0,0 +1 @@ +module.exports = require('expo-module-scripts/jest-preset-plugin'); diff --git a/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts b/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts new file mode 100644 index 00000000000000..5a0d1e91592e04 --- /dev/null +++ b/packages/expo-app-intents/plugin/src/__tests__/withAppIntents.test.ts @@ -0,0 +1,111 @@ +import { WarningAggregator } from 'expo/config-plugins'; + +import withAppIntents, { withAppIntentsValidation } from '../withAppIntents'; + +jest.mock('expo/config-plugins', () => { + const plugins = jest.requireActual('expo/config-plugins'); + return { + ...plugins, + WarningAggregator: { addWarningIOS: jest.fn() }, + }; +}); + +const baseConfig = { name: 'test-app', slug: 'test-app' } as any; + +function configWatching(...watchedDirectories: string[]) { + return { + ...baseConfig, + experiments: { inlineModules: { watchedDirectories } }, + }; +} + +describe(withAppIntentsValidation, () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an actionable error when inline modules are not configured', () => { + expect(() => withAppIntentsValidation(baseConfig, { directory: 'app-intents' })).toThrow( + /experiments\.inlineModules/ + ); + }); + + it('throws when the intents directory is not watched', () => { + const config = configWatching('other'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /app-intents/ + ); + }); + + it('throws when a watched directory only shares a prefix with the intents directory', () => { + const config = configWatching('app-intents-extra'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /app-intents/ + ); + }); + + it('names the plugin directory prop as the other way out', () => { + const config = configWatching('ios-modules'); + expect(() => withAppIntentsValidation(config, { directory: 'app-intents' })).toThrow( + /"directory"/ + ); + }); + + it('passes config through when configured correctly', () => { + const config = configWatching('app-intents'); + expect(withAppIntentsValidation(config, { directory: 'app-intents' })).toBe(config); + }); + + // `expo-modules-autolinking` resolves each watched entry against the app root, so these all + // name the same directory as `app-intents`. + it.each(['./app-intents', 'app-intents/', './app-intents/', 'app-intents/../app-intents'])( + 'accepts the equivalent watched path %s', + (watchedDirectory) => { + const config = configWatching(watchedDirectory); + expect(withAppIntentsValidation(config, { directory: 'app-intents' })).toBe(config); + } + ); + + // Watched directories are scanned recursively, so any ancestor works. + it.each(['.', './', 'src', 'src/native'])( + 'accepts the watched ancestor directory %s', + (watchedDirectory) => { + const config = configWatching(watchedDirectory); + expect(withAppIntentsValidation(config, { directory: 'src/native/app-intents' })).toBe( + config + ); + } + ); + + it('accepts a directory watched alongside unrelated ones', () => { + const config = configWatching('other', 'src'); + expect(withAppIntentsValidation(config, { directory: 'src/app-intents' })).toBe(config); + }); + + // `expo-router` also supports `src/app`, so intents there collide just as badly. + it.each([ + ['app/intents', "inside 'app/'"], + ['src/app/intents', "inside 'src/app/'"], + ])( + 'warns when the intents directory %s is inside the expo-router app directory', + (directory, expectedWarning) => { + const config = { + ...baseConfig, + experiments: { inlineModules: { watchedDirectories: [directory] } }, + }; + + expect(withAppIntents(config, { directory })).toBe(config); + expect(WarningAggregator.addWarningIOS).toHaveBeenCalledWith( + 'expo-app-intents', + expect.stringContaining(expectedWarning) + ); + } + ); + + it('does not warn about a directory that only shares a prefix with the router directory', () => { + const config = configWatching('app-intents'); + + expect(withAppIntents(config, { directory: 'app-intents' })).toBe(config); + expect(WarningAggregator.addWarningIOS).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo-app-intents/plugin/src/index.ts b/packages/expo-app-intents/plugin/src/index.ts new file mode 100644 index 00000000000000..3cb136719b278d --- /dev/null +++ b/packages/expo-app-intents/plugin/src/index.ts @@ -0,0 +1,4 @@ +import { Props } from './withAppIntents'; + +export default (props: Props = {}): [string, Props] => ['expo-app-intents', props]; +export { default as withAppIntents } from './withAppIntents'; diff --git a/packages/expo-app-intents/plugin/src/withAppIntents.ts b/packages/expo-app-intents/plugin/src/withAppIntents.ts new file mode 100644 index 00000000000000..12dbb275261db1 --- /dev/null +++ b/packages/expo-app-intents/plugin/src/withAppIntents.ts @@ -0,0 +1,129 @@ +import type { ExpoConfig } from 'expo/config'; +import { ConfigPlugin, WarningAggregator, createRunOncePlugin } from 'expo/config-plugins'; +import path from 'path'; + +const pkg = require('../../package.json'); + +export type Props = { + /** + * The watched directory containing app-target App Intents Swift files. + * @default 'app-intents' + */ + directory?: string; +}; + +const DEFAULT_DIRECTORY = 'app-intents'; + +/** + * The directories `expo-router` treats as the routes directory. It looks for `app/` at the project + * root and, when that is missing, for `src/app/`, so both collide with intents placed inside them. + */ +const ROUTER_APP_DIRECTORIES = ['app', 'src/app']; + +type ValidationConfig = Pick; + +/** + * Returns the directory that relative entries are resolved against. + */ +function projectRootOf(config: Pick): string { + return (config._internal?.projectRoot as string | undefined) ?? process.cwd(); +} + +/** + * Returns whether `directory` is `ancestor` itself or a directory nested inside it. + */ +function isSameOrInside(ancestor: string, directory: string, projectRoot: string): boolean { + const relativePath = path.relative( + path.resolve(projectRoot, ancestor), + path.resolve(projectRoot, directory) + ); + + if (relativePath === '') { + return true; + } + if (path.isAbsolute(relativePath)) { + return false; + } + // Only a whole `..` segment leaves `ancestor`. A segment such as `'..intents'` is an ordinary + // directory whose name begins with two dots. + return !relativePath.split(path.sep).includes('..'); +} + +/** + * Returns whether inline modules scan `directory`. + * + * `expo-modules-autolinking` scans every watched entry recursively, so any ancestor of the intents + * directory is a working configuration. + */ +function isWatchedDirectory( + watchedDirectories: string[], + directory: string, + projectRoot: string +): boolean { + return watchedDirectories.some((watchedDirectory) => + isSameOrInside(watchedDirectory, directory, projectRoot) + ); +} + +function buildsForIOS(config: Pick): boolean { + return !config.platforms || config.platforms.includes('ios'); +} + +export function withAppIntentsValidation( + config: T, + props: { directory: string } +): T { + const watchedDirectories = config.experiments?.inlineModules?.watchedDirectories; + const projectRoot = projectRootOf(config); + + if ( + !watchedDirectories || + !isWatchedDirectory(watchedDirectories, props.directory, projectRoot) + ) { + const watchedList = watchedDirectories?.length + ? watchedDirectories.map((directory) => `'${directory}'`).join(', ') + : 'nothing'; + + throw new Error( + `expo-app-intents cannot build the App Intents in '${props.directory}'. Apple's build-time ` + + `metadata extraction cannot see code inside pods, so those Swift files have to be ` + + `compiled into the iOS app target itself. Expo Inline Modules does that, but it only ` + + `scans the directories listed in expo.experiments.inlineModules.watchedDirectories, ` + + `which currently covers ${watchedList}.\n\n` + + `Add the directory to your app config and re-run prebuild:\n\n` + + ` "experiments": { "inlineModules": { "watchedDirectories": ["${props.directory}"] } }\n\n` + + `Watched directories are scanned recursively, so a parent directory works too. If your ` + + `intents already live in a directory that is watched, point the plugin at it instead with ` + + `the "directory" prop:\n\n` + + ` ["expo-app-intents", { "directory": "your-watched-directory" }]\n\n` + + `Or run \`npx expo-app-intents init\` to configure everything automatically.` + ); + } + return config; +} + +const withAppIntents: ConfigPlugin = (config, props) => { + if (!buildsForIOS(config)) { + return config; + } + + const directory = props?.directory ?? DEFAULT_DIRECTORY; + + // Normalised the same way as the watched-directory check, so `'./app/intents'` is recognised. + const projectRoot = projectRootOf(config); + const routerDirectory = ROUTER_APP_DIRECTORIES.find((routesDirectory) => + isSameOrInside(routesDirectory, directory, projectRoot) + ); + if (routerDirectory) { + WarningAggregator.addWarningIOS( + 'expo-app-intents', + `The configured intents directory '${directory}' is inside '${routerDirectory}/', which ` + + `expo-router treats as the routes directory. Use a top-level 'app-intents/' directory ` + + `instead.` + ); + } + + return withAppIntentsValidation(config, { directory }); +}; + +export default createRunOncePlugin(withAppIntents, pkg.name, pkg.version); diff --git a/packages/expo-app-intents/plugin/tsconfig.json b/packages/expo-app-intents/plugin/tsconfig.json new file mode 100644 index 00000000000000..9d87417cc8b573 --- /dev/null +++ b/packages/expo-app-intents/plugin/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo-module-scripts/tsconfig.plugin", + "compilerOptions": { + "rootDir": "./src", + "outDir": "./build", + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/expo-app-intents/src/ExpoAppIntents.types.ts b/packages/expo-app-intents/src/ExpoAppIntents.types.ts new file mode 100644 index 00000000000000..300ce04a6b8fe4 --- /dev/null +++ b/packages/expo-app-intents/src/ExpoAppIntents.types.ts @@ -0,0 +1,59 @@ +/** + * A single recorded App Intent invocation. + * + * Invocations are persisted natively until removed with `removePendingInvocationAsync`, so + * delivery is at-least-once and handlers must be idempotent per `id`. + */ +export type AppIntentInvocation = { + /** + * Unique identifier of this invocation. Use it to remove the invocation after handling. + */ + id: string; + /** + * The invocation name passed to `await AppIntentDispatcher.shared.dispatch(name:params:)` in Swift. + */ + name: string; + /** + * Parameters passed from the native intent. + */ + params: Record; + /** + * Unix timestamp in milliseconds at which the intent ran. + */ + createdAt: number; +}; + +/** + * Handles a snapshot of pending invocations and, after the initial call, the new invocation + * that triggered this handler call. + */ +export type AppIntentsHandler = ( + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null +) => void | Promise; + +/** + * An entity exposed to App Intents parameter queries. + */ +export type AppIntentEntity = { + /** + * Stable unique identifier. + */ + id: string; + /** + * Display name shown by Siri and the Shortcuts app, and matched against speech. + */ + title: string; + /** + * Optional secondary text shown in disambiguation UI. + */ + subtitle?: string; + /** + * Alternative spoken names that resolve to this entity. + */ + synonyms?: string[]; +}; + +export type ExpoAppIntentsModuleEvents = { + onIntent: (invocation: AppIntentInvocation) => void; +}; diff --git a/packages/expo-app-intents/src/ExpoAppIntentsModule.ts b/packages/expo-app-intents/src/ExpoAppIntentsModule.ts new file mode 100644 index 00000000000000..be2045ec015599 --- /dev/null +++ b/packages/expo-app-intents/src/ExpoAppIntentsModule.ts @@ -0,0 +1,18 @@ +import { NativeModule, requireOptionalNativeModule } from 'expo-modules-core'; + +import type { + AppIntentEntity, + AppIntentInvocation, + ExpoAppIntentsModuleEvents, +} from './ExpoAppIntents.types'; + +declare class ExpoAppIntentsNativeModule extends NativeModule { + getPendingInvocationsAsync(): Promise; + removePendingInvocationAsync(id: string): Promise; + clearPendingInvocationsAsync(): Promise; + setEntityCatalogAsync(kind: string, entities: AppIntentEntity[]): Promise; + getEntityCatalogAsync(kind: string): Promise; + refreshShortcutsAsync(): Promise; +} + +export default requireOptionalNativeModule('ExpoAppIntents'); diff --git a/packages/expo-app-intents/src/__tests__/index.test.ts b/packages/expo-app-intents/src/__tests__/index.test.ts new file mode 100644 index 00000000000000..7c3aad652a57fe --- /dev/null +++ b/packages/expo-app-intents/src/__tests__/index.test.ts @@ -0,0 +1,37 @@ +import * as AppIntents from '../index'; + +describe('expo-app-intents on unsupported platforms', () => { + it('reports unavailability', () => { + expect(AppIntents.isAvailable()).toBe(false); + }); + + it('returns empty pending invocations', async () => { + await expect(AppIntents.getPendingInvocationsAsync()).resolves.toEqual([]); + }); + + it('returns an empty entity catalog', async () => { + await expect(AppIntents.getEntityCatalogAsync('dish')).resolves.toEqual([]); + }); + + it('resolves no-op mutations', async () => { + await expect(AppIntents.removePendingInvocationAsync('x')).resolves.toBeUndefined(); + await expect(AppIntents.clearPendingInvocationsAsync()).resolves.toBeUndefined(); + await expect( + AppIntents.setEntityCatalogAsync('dish', [{ id: 'margherita', title: 'Margherita Pizza' }]) + ).resolves.toBeUndefined(); + }); + + it('rejects refreshShortcutsAsync with UnavailabilityError', async () => { + await expect(AppIntents.refreshShortcutsAsync()).rejects.toThrow(/not available/); + }); + + it('returns an inert subscription from addAppIntentListener', () => { + const subscription = AppIntents.addAppIntentListener(() => {}); + expect(typeof subscription.remove).toBe('function'); + subscription.remove(); + }); + + it('exports useAppIntents hook', () => { + expect(typeof AppIntents.useAppIntents).toBe('function'); + }); +}); diff --git a/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx b/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx new file mode 100644 index 00000000000000..a0981676c98f49 --- /dev/null +++ b/packages/expo-app-intents/src/__tests__/useAppIntents.test.native.tsx @@ -0,0 +1,154 @@ +import { renderHook, waitFor } from '@testing-library/react-native'; + +import type { AppIntentInvocation } from '../ExpoAppIntents.types'; +import ExpoAppIntents from '../ExpoAppIntentsModule'; +import { useAppIntents } from '../index'; + +jest.mock('../ExpoAppIntentsModule', () => { + const listeners = new Set(); + return { + __esModule: true, + default: { + addListener: jest.fn((eventName, listener) => { + listeners.add(listener); + return { + remove: () => { + listeners.delete(listener); + }, + }; + }), + getPendingInvocationsAsync: jest.fn(async () => []), + }, + }; +}); + +function makeInvocation(id: string, name: string): AppIntentInvocation { + return { id, name, params: {}, createdAt: 0 }; +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +/** + * Lets every task that is not blocked on a still-pending promise run to completion, so a + * deferred promise resolved afterwards cannot win a race by accident of queueing order. + */ +async function flushMicrotasks() { + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +const getPendingMock = ExpoAppIntents!.getPendingInvocationsAsync as jest.Mock; +const addListenerMock = ExpoAppIntents!.addListener as jest.Mock; + +function emitIntent(invocation: AppIntentInvocation) { + for (const call of addListenerMock.mock.calls) { + call[1](invocation); + } +} + +describe(useAppIntents, () => { + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + getPendingMock.mockImplementation(async () => []); + }); + + it('continues delivery after the handler throws synchronously', async () => { + jest.spyOn(console, 'warn').mockImplementation(() => {}); + const cold = makeInvocation('cold', 'coldIntent'); + getPendingMock.mockResolvedValue([cold]); + + const handler = jest.fn().mockImplementationOnce(() => { + throw new Error('handler failed'); + }); + renderHook(() => useAppIntents(handler)); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + expect(handler).toHaveBeenCalledWith([cold], null); + emitIntent(makeInvocation('live', 'liveIntent')); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + }); + + it('delivers the initial snapshot before a live invocation that arrives during mount', async () => { + const cold = makeInvocation('cold', 'coldIntent'); + const live = makeInvocation('live', 'liveIntent'); + + // The initial pending read stays in flight until after the live invocation arrives, so a + // handler call for the live invocation could otherwise resolve first. + const initialRead = deferred(); + getPendingMock + .mockImplementationOnce(() => initialRead.promise) + .mockImplementation(async () => [cold, live]); + + const handler = jest.fn(); + renderHook(() => useAppIntents(handler)); + + emitIntent(live); + // Let the live invocation's delivery run as far as it can before the initial read resolves. + await flushMicrotasks(); + // The live invocation was persisted natively before its event fired, so it is part of the + // snapshot the initial read returns. + initialRead.resolve([cold, live]); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + expect(handler).toHaveBeenNthCalledWith(1, [cold], null); + expect(handler).toHaveBeenNthCalledWith(2, [cold, live], live); + }); + + it('delivers live invocations in arrival order', async () => { + const first = makeInvocation('first', 'firstIntent'); + const second = makeInvocation('second', 'secondIntent'); + + // The first live invocation's pending read resolves after the second one's, so delivery + // order must come from the hook, not from promise resolution order. + const firstRead = deferred(); + const firstDelivery = deferred(); + getPendingMock + .mockImplementationOnce(async () => []) + .mockImplementationOnce(() => firstRead.promise) + .mockImplementation(async () => [first, second]); + + const handler = jest.fn((_pending, newIntent) => + newIntent?.id === first.id ? firstDelivery.promise : undefined + ); + renderHook(() => useAppIntents(handler)); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + emitIntent(first); + emitIntent(second); + // Let the second invocation's delivery run as far as it can while the first one's pending + // read is still in flight. + await flushMicrotasks(); + firstRead.resolve([first]); + + await waitFor(() => expect(handler).toHaveBeenCalledTimes(2)); + await flushMicrotasks(); + expect(handler).toHaveBeenCalledTimes(2); + firstDelivery.resolve(); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(3)); + expect(handler).toHaveBeenNthCalledWith(2, [first], first); + expect(handler).toHaveBeenNthCalledWith(3, [first, second], second); + }); + + it('does not redeliver an initial pending invocation as live', async () => { + const live = makeInvocation('live', 'liveIntent'); + getPendingMock.mockImplementation(async () => [live]); + + const handler = jest.fn(); + renderHook(() => useAppIntents(handler)); + await waitFor(() => expect(handler).toHaveBeenCalledTimes(1)); + + emitIntent(live); + emitIntent(live); + + await flushMicrotasks(); + expect(handler).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo-app-intents/src/index.ts b/packages/expo-app-intents/src/index.ts new file mode 100644 index 00000000000000..c93b7bfbd058b2 --- /dev/null +++ b/packages/expo-app-intents/src/index.ts @@ -0,0 +1,243 @@ +import { type EventSubscription, UnavailabilityError } from 'expo-modules-core'; +import { useEffect, useRef } from 'react'; + +import type { + AppIntentEntity, + AppIntentInvocation, + AppIntentsHandler, +} from './ExpoAppIntents.types'; +import ExpoAppIntents from './ExpoAppIntentsModule'; + +export type * from './ExpoAppIntents.types'; + +const MAX_SEEN_INVOCATION_IDS = 100; + +/** + * Returns whether App Intents are available on this device. + * Returns `false` on Android, and web. + * @platform ios + */ +export function isAvailable(): boolean { + return ExpoAppIntents != null; +} + +/** + * Adds a listener invoked for live App Intent invocations dispatched while JavaScript is + * observing. + * + * > Use [`getPendingInvocationsAsync()`](#appintentsgetpendinginvocationsasync) or + * > [`useAppIntents()`](#appintentsuseappintentshandler) to read invocations recorded while + * > JavaScript was not running. + * @platform ios + */ +export function addAppIntentListener( + listener: (invocation: AppIntentInvocation) => void +): EventSubscription { + if (!ExpoAppIntents) { + return { remove() {} }; + } + return ExpoAppIntents.addListener('onIntent', listener); +} + +function callAppIntentsHandler( + handler: AppIntentsHandler, + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null +) { + return Promise.resolve() + .then(() => handler(pendingIntents, newIntent)) + .catch((error: unknown) => { + console.warn('Unhandled error in useAppIntents handler.', error); + }); +} + +/** + * Calls `handler` once with the pending invocations recorded while JavaScript was cold, then + * again for every new invocation received while the component is mounted. + * + * `newIntent` is `null` for the initial pending snapshot. Later calls include the current + * pending snapshot and the new invocation that triggered the call. The initial call is always + * delivered first, and new invocations are delivered one at a time in arrival order. + * Pending invocations are not removed automatically; call + * [`removePendingInvocationAsync(id)`](#appintentsremovependinginvocationasyncid) + * after handling each one. The queue holds at most 100 invocations, and once it is full the oldest + * are dropped to make room, so a handler that never removes them does eventually lose invocations. + * + * The handler is called with an empty snapshot, and never again, when App Intents are unavailable. + * @platform ios + */ +export function useAppIntents(handler: AppIntentsHandler): void { + const handlerRef = useRef(handler); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + useEffect(() => { + let isMounted = true; + const seenLiveInvocationIds = new Set(); + let deliveryQueue: Promise = Promise.resolve(); + const enqueue = (deliver: () => Promise) => { + deliveryQueue = deliveryQueue.then(deliver); + }; + + const notify = ( + pendingIntents: AppIntentInvocation[], + newIntent: AppIntentInvocation | null + ) => { + if (!isMounted) { + return; + } + return callAppIntentsHandler(handlerRef.current, pendingIntents, newIntent); + }; + + const deliverNewIntent = async (newIntent: AppIntentInvocation) => { + try { + const pendingIntents = await getPendingInvocationsAsync(); + await notify(pendingIntents.length > 0 ? pendingIntents : [newIntent], newIntent); + } catch (error) { + if (isMounted) { + console.error('Could not read pending App Intents invocations.', error); + await notify([newIntent], newIntent); + } + } + }; + + const deliverInitialPendingIntents = async () => { + try { + const pendingIntents = await getPendingInvocationsAsync(); + const initialPendingIntents = pendingIntents.filter( + (invocation) => !seenLiveInvocationIds.has(invocation.id) + ); + initialPendingIntents.forEach(({ id }) => seenLiveInvocationIds.add(id)); + await notify(initialPendingIntents, null); + } catch (error) { + if (isMounted) { + console.error('Could not read pending App Intents invocations.', error); + await notify([], null); + } + } + }; + + // Attach the live listener first so an invocation cannot arrive between reading pending + // invocations and subscribing to future ones. + const subscription = addAppIntentListener((newIntent) => { + if (seenLiveInvocationIds.has(newIntent.id)) { + return; + } + seenLiveInvocationIds.add(newIntent.id); + if (seenLiveInvocationIds.size > MAX_SEEN_INVOCATION_IDS) { + seenLiveInvocationIds.delete(seenLiveInvocationIds.values().next().value!); + } + enqueue(() => deliverNewIntent(newIntent)); + }); + + enqueue(() => deliverInitialPendingIntents()); + + return () => { + isMounted = false; + subscription.remove(); + }; + }, []); +} + +/** + * Returns invocations that have not been removed from the pending queue yet, oldest first. + * Resolves with an empty array when App Intents are unavailable. + * + * At most 100 invocations are kept. An app that never removes them keeps only the newest 100. + * + * Rejects when the stored queue cannot be read, which means the invocations waiting in it are not + * delivered. The queue starts empty afterwards, so a later call succeeds. + * @platform ios + */ +export async function getPendingInvocationsAsync(): Promise { + if (!ExpoAppIntents) { + return []; + } + return ExpoAppIntents.getPendingInvocationsAsync(); +} + +/** + * Removes a handled invocation so it is no longer delivered or returned as pending. + * Does nothing when App Intents are unavailable. + * + * Rejects when the stored queue cannot be read or written, so a failure to forget a handled + * invocation is not mistaken for success. A rejection caused by an unreadable queue leaves nothing + * pending at all: the unreadable data is set aside, so every invocation that was waiting in it is + * gone, not only this one. + * @platform ios + */ +export async function removePendingInvocationAsync(id: string): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.removePendingInvocationAsync(id); +} + +/** + * Removes all pending invocations. + * Does nothing when App Intents are unavailable. + * @platform ios + */ +export async function clearPendingInvocationsAsync(): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.clearPendingInvocationsAsync(); +} + +/** + * Replaces the entity catalog of the given kind and asks the system to re-train + * parameterized shortcut phrases against the new values. + * + * The native store is UserDefaults-backed, so it's recommended to keep catalogs compact. For large + * datasets such as thousands of contacts, songs, or other items, store the full + * data in your app and publish only the subset that Siri and Shortcuts need. + * + * When `kind` or a provided entity is invalid, the whole catalog is rejected, and the previous + * one kept. The `kind` is invalid when it is empty or whitespace-only. An entity is invalid when + * its `id` or `title` is empty or whitespace-only, or when another entity in the catalog has the + * same `id`. + * @platform ios + */ +export async function setEntityCatalogAsync( + kind: string, + entities: AppIntentEntity[] +): Promise { + if (!ExpoAppIntents) { + return; + } + return ExpoAppIntents.setEntityCatalogAsync(kind, entities); +} + +/** + * Returns the current entity catalog of the given kind. + * Resolves with an empty array when the kind was never published, and when App Intents are + * unavailable. + * + * Rejects when the stored catalog cannot be read. + * @platform ios + */ +export async function getEntityCatalogAsync(kind: string): Promise { + if (!ExpoAppIntents) { + return []; + } + return ExpoAppIntents.getEntityCatalogAsync(kind); +} + +/** + * Asks the system to re-evaluate App Shortcut phrases and parameter values. + * + * Throws `UnavailabilityError` when App Intents are unavailable, and throws when they are available + * but the app has no `AppShortcutsProvider`, and so nothing to refresh. Publishing a catalog with + * [`setEntityCatalogAsync()`](#appintentssetentitycatalogasynckind-entities) also refreshes + * shortcuts. + * @platform ios + */ +export async function refreshShortcutsAsync(): Promise { + if (!ExpoAppIntents) { + throw new UnavailabilityError('expo-app-intents', 'refreshShortcutsAsync'); + } + return ExpoAppIntents.refreshShortcutsAsync(); +} diff --git a/packages/expo-app-intents/tsconfig.all.json b/packages/expo-app-intents/tsconfig.all.json new file mode 100644 index 00000000000000..e952403770f319 --- /dev/null +++ b/packages/expo-app-intents/tsconfig.all.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.json" + }, + { + "path": "./plugin" + } + ] +} diff --git a/packages/expo-app-intents/tsconfig.json b/packages/expo-app-intents/tsconfig.json new file mode 100644 index 00000000000000..55589cf2d15c96 --- /dev/null +++ b/packages/expo-app-intents/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "expo-module-scripts/tsconfig.base", + "compilerOptions": { + "outDir": "./build", + "rootDir": "./src", + "types": ["node"] + }, + "include": ["./src"], + "exclude": ["**/__mocks__/*", "**/__tests__/*"] +} diff --git a/packages/expo-audio/CHANGELOG.md b/packages/expo-audio/CHANGELOG.md index b20afa3b2267a9..3659ac8b391d18 100644 --- a/packages/expo-audio/CHANGELOG.md +++ b/packages/expo-audio/CHANGELOG.md @@ -4,6 +4,7 @@ ### 🛠 Breaking changes +- Paused time is now excluded from the `AudioRecorder` duration limit. ([#49239](https://github.com/expo/expo/pull/49239) by [@stvrmrz](https://github.com/stvrmrz) and [@behenate](https://github.com/behenate)) - [Android] Aligned the default audio focus request on Android 7.0–7.1 with newer versions by using transient exclusive focus when no interruption mode has been configured. ([#49101](https://github.com/expo/expo/pull/49101) by [@behenate](https://github.com/behenate)) ### 🎉 New features @@ -18,6 +19,7 @@ ### 🐛 Bug fixes +- Preserve recorder duration limits across pauses, consistently exclude paused time on Android, iOS, and Web. ([#49239](https://github.com/expo/expo/pull/49239) by [@stvrmrz](https://github.com/stvrmrz) and [@behenate](https://github.com/behenate)) - [Android] Pause audio players and playlists when headphones or Bluetooth audio devices disconnect. ([#48151](https://github.com/expo/expo/pull/48151) by [@vivekjm](https://github.com/vivekjm)) - [Android] Give the lock-screen `MediaSession` instances a unique ID so concurrent active players (and the basic session) no longer collide on the empty default. ([#47101](https://github.com/expo/expo/issues/47101) by [@tsushanth](https://github.com/tsushanth)) - [Android] Fix stale lock screen artwork when updating metadata without an `artworkUrl`. ([#45738](https://github.com/expo/expo/pull/45738) by [@behenate](https://github.com/behenate)) @@ -27,6 +29,7 @@ - [iOS] Activate the audio session once and keep it active instead of toggling. ([#48588](https://github.com/expo/expo/pull/48588) by [@alanjhughes](https://github.com/alanjhughes)) - [Android] Fix `createAudioPlayer`/`useAudioPlayer` throwing "Received 5 arguments, but 4 was expected" due to the native `AudioPlayer` constructor missing the iOS-only `allowsExternalPlayback` parameter. ([#48655](https://github.com/expo/expo/pull/48655) by [@RasmusKard](https://github.com/RasmusKard)) - [iOS] Report `denied` instead of crashing the app when `NSMicrophoneUsageDescription` is missing. ([#48840](https://github.com/expo/expo/pull/48840) by [@ahmadaccino](https://github.com/ahmadaccino)) +- [iOS] Resolve permission requests with `denied` and reject recording calls instead of letting the OS terminate the app when `NSMicrophoneUsageDescription` is missing. ([#49162](https://github.com/expo/expo/pull/49162) by [@alanjhughes](https://github.com/alanjhughes)) ### 💡 Others diff --git a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt index 8f80ba519ce28a..3150dcb8ac4a8a 100644 --- a/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt +++ b/packages/expo-audio/android/src/main/java/expo/modules/audio/AudioRecorder.kt @@ -47,6 +47,7 @@ class AudioRecorder( var isRecording = false var isPaused = false private var recordingTimerJob: Job? = null + private var durationLimitMillis: Long? = null var useForegroundService = false val serviceConnection = AudioRecordingServiceConnection(WeakReference(this), appContext) @@ -118,6 +119,11 @@ class AudioRecorder( } } + if (isPaused && durationLimitMillis?.let { durationAlreadyRecorded >= it } == true) { + stopRecording() + return + } + if (isPaused) { recorder?.resume() } else { @@ -126,26 +132,23 @@ class AudioRecorder( startTime = System.currentTimeMillis() isRecording = true isPaused = false + scheduleRecordingStop() } fun recordWithOptions(atTimeSeconds: Double? = null, forDurationSeconds: Double? = null) { recordingTimerJob?.cancel() + recordingTimerJob = null // Note: atTime is not supported on Android (no native equivalent), so we ignore it entirely // Only forDuration is implemented using coroutines + if (forDurationSeconds != null) { + durationLimitMillis = (forDurationSeconds * 1000).toLong() + } else if (!isPaused || atTimeSeconds != null) { + // A bare record() resumes the current capture and keeps its limit. + durationLimitMillis = null + } - forDurationSeconds?.let { - record() - recordingTimerJob = appContext?.mainQueue?.launch { - delay((it * 1000).toLong()) - // Stop recording regardless of current state - // This matches the iOS behaviour where the timer continues regardless of if - // the recording was paused. - if (isRecording || isPaused) { - stopRecording() - } - } - } ?: record() + record() } // Keep backward compatibility methods @@ -158,12 +161,26 @@ class AudioRecorder( } fun pauseRecording() { + recordingTimerJob?.cancel() + recordingTimerJob = null recorder?.pause() durationAlreadyRecorded = getAudioRecorderDurationMillis() isRecording = false isPaused = true } + private fun scheduleRecordingStop() { + recordingTimerJob?.cancel() + recordingTimerJob = durationLimitMillis?.let { limit -> + appContext?.mainQueue?.launch { + delay((limit - getAudioRecorderDurationMillis()).coerceAtLeast(0)) + if (isRecording) { + stopRecording() + } + } + } + } + fun stopRecording(): Bundle { val url = currentFileUrl() var durationMillis: Long @@ -220,6 +237,7 @@ class AudioRecorder( isRecording = false isPaused = false durationAlreadyRecorded = 0 + durationLimitMillis = null startTime = 0L isPrepared = false } diff --git a/packages/expo-audio/ios/AudioExceptions.swift b/packages/expo-audio/ios/AudioExceptions.swift index 12d82003955e0f..99a5aa26f0f7c0 100644 --- a/packages/expo-audio/ios/AudioExceptions.swift +++ b/packages/expo-audio/ios/AudioExceptions.swift @@ -18,6 +18,12 @@ internal final class AudioPermissionsException: Exception { } } +internal final class MicrophoneUsageDescriptionException: Exception { + override var reason: String { + "Cannot record audio because NSMicrophoneUsageDescription is missing from the app's Info.plist" + } +} + internal final class InvalidAudioModeException: GenericException { override var reason: String { "Impossible audio mode: \(param)" diff --git a/packages/expo-audio/ios/AudioModule.swift b/packages/expo-audio/ios/AudioModule.swift index 65ea4095527262..bad776eb03020e 100644 --- a/packages/expo-audio/ios/AudioModule.swift +++ b/packages/expo-audio/ios/AudioModule.swift @@ -485,6 +485,7 @@ public class AudioModule: Module { } AsyncFunction("prepareToRecordAsync") { (recorder, options: RecordingOptions?) in + try AudioRecordingRequester.requireUsageDescription() let deactivateSessionOnFailure = sessionQueue.sync { !sessionIsActive && audioSessionActivityKeepers.isEmpty } @@ -503,18 +504,15 @@ public class AudioModule: Module { case let (atTime?, forDuration?): // Convert relative delay to absolute device time let absoluteTime = recorder.ref.deviceCurrentTime + TimeInterval(atTime) - recorder.ref.record(atTime: absoluteTime, forDuration: TimeInterval(forDuration)) - recorder.updateStateForDirectRecording() + recorder.recordForDuration(TimeInterval(forDuration), atTime: absoluteTime) return recorder.getRecordingStatus() case let (atTime?, nil): // Convert relative delay to absolute device time let absoluteTime = recorder.ref.deviceCurrentTime + TimeInterval(atTime) - recorder.ref.record(atTime: absoluteTime) - recorder.updateStateForDirectRecording() + recorder.record(atTime: absoluteTime) return recorder.getRecordingStatus() case let (nil, forDuration?): - recorder.ref.record(forDuration: TimeInterval(forDuration)) - recorder.updateStateForDirectRecording() + recorder.recordForDuration(TimeInterval(forDuration)) return recorder.getRecordingStatus() case (nil, nil): return try recorder.startRecording() @@ -537,12 +535,12 @@ public class AudioModule: Module { Function("startRecordingAtTime") { (recorder, seconds: Double) in try checkPermissions() - recorder.ref.record(atTime: TimeInterval(seconds)) + recorder.record(atTime: TimeInterval(seconds)) } Function("recordForDuration") { (recorder, seconds: Double) in try checkPermissions() - recorder.ref.record(forDuration: TimeInterval(seconds)) + recorder.recordForDuration(TimeInterval(seconds)) } Function("getAvailableInputs") { @@ -1020,6 +1018,8 @@ public class AudioModule: Module { private func checkPermissions() throws { #if os(iOS) + try AudioRecordingRequester.requireUsageDescription() + if #available(iOS 17.0, *) { switch AVAudioApplication.shared.recordPermission { case .denied, .undetermined: diff --git a/packages/expo-audio/ios/AudioRecorder.swift b/packages/expo-audio/ios/AudioRecorder.swift index a6d148f9531b77..73c6a036be6c76 100644 --- a/packages/expo-audio/ios/AudioRecorder.swift +++ b/packages/expo-audio/ios/AudioRecorder.swift @@ -23,6 +23,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { private var mediaServicesDidReset = false private var currentOptions: RecordingOptions? private var currentSessionOptions: AVAudioSession.CategoryOptions = [] + private var durationLimitSeconds: TimeInterval? private var isPrepared: Bool { currentState == .prepared || currentState == .recording || currentState == .paused @@ -85,6 +86,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { ref.stop() } resetDurationTracking() + durationLimitSeconds = nil mediaServicesDidReset = false let session = AVAudioSession.sharedInstance() @@ -127,6 +129,32 @@ class AudioRecorder: SharedRef, RecordingResultHandler { private func resetDurationTracking() { startTimestamp = 0 totalRecordedDuration = 0 + // `durationLimitSeconds` is deliberately NOT cleared here. This helper runs + // from `updateStateForDirectRecording()`, which is called *after* a + // `record(forDuration:)` has already armed the limit, so clearing it here + // would discard the limit the caller just set. It is cleared at capture + // boundaries instead. + } + + func recordForDuration(_ seconds: TimeInterval, atTime absoluteTime: TimeInterval? = nil) { + if currentState == .paused && ref.currentTime >= seconds { + stopRecording() + return + } + + if let absoluteTime { + ref.record(atTime: absoluteTime, forDuration: seconds) + } else { + ref.record(forDuration: seconds) + } + updateStateForDirectRecording() + durationLimitSeconds = seconds + } + + func record(atTime absoluteTime: TimeInterval) { + ref.record(atTime: absoluteTime) + updateStateForDirectRecording() + durationLimitSeconds = nil } func startRecording() throws -> [String: Any] { @@ -142,7 +170,17 @@ class AudioRecorder: SharedRef, RecordingResultHandler { resetDurationTracking() } - ref.record() + // If the recording was armed with a duration, re-apply it. + if let limit = durationLimitSeconds { + if ref.currentTime >= limit { + // The allowance is already spent; stop rather than resume. + stopRecording() + return getRecordingStatus() + } + ref.record(forDuration: limit) + } else { + ref.record() + } startTimestamp = deviceCurrentTime currentState = .recording @@ -166,6 +204,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { ref.stop() currentState = .stopped resetDurationTracking() + durationLimitSeconds = nil } func pauseRecording() { @@ -200,6 +239,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { func handleMediaServicesReset() { mediaServicesDidReset = true resetDurationTracking() + durationLimitSeconds = nil if let options = currentOptions { do { @@ -231,6 +271,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { // Update internal state when recording finishes automatically (e.g., from recordForDuration) currentState = .stopped resetDurationTracking() + durationLimitSeconds = nil emit(event: recordingStatus, payload: [ "id": id, @@ -245,6 +286,7 @@ class AudioRecorder: SharedRef, RecordingResultHandler { // Update internal state on error currentState = .error resetDurationTracking() + durationLimitSeconds = nil emit(event: recordingStatus, payload: [ "id": id, diff --git a/packages/expo-audio/ios/AudioRecordingRequester.swift b/packages/expo-audio/ios/AudioRecordingRequester.swift index fa50882a9f053b..e813e521d62f9e 100644 --- a/packages/expo-audio/ios/AudioRecordingRequester.swift +++ b/packages/expo-audio/ios/AudioRecordingRequester.swift @@ -7,10 +7,14 @@ public class AudioRecordingRequester: NSObject, EXPermissionsRequester { return "audioRecording" } + static var microphoneUsageDescription: Any? { + Bundle.main.infoDictionary?["NSMicrophoneUsageDescription"] + } + public func getPermissions() -> [AnyHashable: Any] { return Self.permissions( systemStatus: AVAudioSession.sharedInstance().recordPermission, - usageDescription: Bundle.main.object(forInfoDictionaryKey: "NSMicrophoneUsageDescription") + usageDescription: Self.microphoneUsageDescription ) } @@ -45,7 +49,22 @@ public class AudioRecordingRequester: NSObject, EXPermissionsRequester { ] } + static func requireUsageDescription(_ usageDescription: Any? = AudioRecordingRequester.microphoneUsageDescription) throws { + guard usageDescription != nil else { + throw MicrophoneUsageDescriptionException() + } + } + public func requestPermissions(resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: @escaping EXPromiseRejectBlock) { + requestPermissions(usageDescription: Self.microphoneUsageDescription, resolver: resolve, rejecter: reject) + } + + func requestPermissions(usageDescription: Any?, resolver resolve: @escaping EXPromiseResolveBlock, rejecter reject: @escaping EXPromiseRejectBlock) { + guard usageDescription != nil else { + resolve(Self.permissions(systemStatus: AVAudioSession.sharedInstance().recordPermission, usageDescription: nil)) + return + } + typealias PermissionRequestFunction = @convention(c) (AnyObject, Selector, @escaping (Bool) -> Void) -> Void let recordPermissionSelector = NSSelectorFromString(selector.joined()) diff --git a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift index a024f734c9a399..e278ac6f9b21db 100644 --- a/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift +++ b/packages/expo-audio/ios/Tests/AudioRecordingRequesterTests.swift @@ -30,5 +30,31 @@ struct AudioRecordingRequesterTests { #expect(permissions["status"] as? UInt32 == expected.rawValue) } + + @Test + func `resolves denied without a system request when the usage description is missing`() { + let requester = AudioRecordingRequester() + var resolved: [AnyHashable: Any]? + + requester.requestPermissions(usageDescription: nil) { result in + resolved = result as? [AnyHashable: Any] + } rejecter: { _, _, _ in + Issue.record("requestPermissions rejected instead of resolving") + } + + #expect(resolved?["status"] as? UInt32 == EXPermissionStatusDenied.rawValue) + } + + @Test + func `requireUsageDescription throws when the usage description is missing`() { + #expect(throws: MicrophoneUsageDescriptionException.self) { + try AudioRecordingRequester.requireUsageDescription(nil) + } + } + + @Test + func `requireUsageDescription accepts a present usage description`() throws { + try AudioRecordingRequester.requireUsageDescription("Allow $(PRODUCT_NAME) to access your microphone") + } } #endif diff --git a/packages/expo-audio/src/Audio.types.ts b/packages/expo-audio/src/Audio.types.ts index 89e76b444d775f..fe5f5c3a08d2be 100644 --- a/packages/expo-audio/src/Audio.types.ts +++ b/packages/expo-audio/src/Audio.types.ts @@ -370,8 +370,9 @@ export type BitRateStrategy = 'constant' | 'longTermAverage' | 'variableConstrai */ export type RecordingStartOptions = { /** - * The duration in seconds after which recording should automatically stop. - * If not provided, recording continues until manually stopped. + * The maximum duration of recorded audio, in seconds. Time spent paused does not count + * toward the limit. A limited recording keeps the same limit when resumed without options. + * If not provided when starting a new recording, recording continues until manually stopped. * * @platform ios * @platform android diff --git a/packages/expo-audio/src/AudioRecorder.web.ts b/packages/expo-audio/src/AudioRecorder.web.ts index c85d5d6cf2a962..707dd38063af47 100644 --- a/packages/expo-audio/src/AudioRecorder.web.ts +++ b/packages/expo-audio/src/AudioRecorder.web.ts @@ -20,6 +20,8 @@ export class AudioRecorderWeb } async setup() { + this.clearTimeouts(); + this.durationLimitMillis = null; this.mediaRecorder = await this.createMediaRecorder(this.options); } @@ -32,6 +34,7 @@ export class AudioRecorderWeb private mediaRecorderUptimeOfLastStartResume = 0; private mediaRecorderIsRecording = false; private timeoutIds: ReturnType[] = []; + private durationLimitMillis: number | null = null; private cachedInputs: RecordingInput[] = []; private selectedDeviceId: string | null = null; private stream: MediaStream | null = null; @@ -53,22 +56,31 @@ export class AudioRecorderWeb ); } - // Clear any existing timeouts + const wasPaused = this.mediaRecorder.state === 'paused'; this.clearTimeouts(); // Note: atTime is not supported on Web (no native equivalent), so we ignore it entirely // Only forDuration is implemented using setTimeout - const { forDuration } = options || {}; - - this.startActualRecording(); - + const { atTime, forDuration } = options || {}; if (forDuration !== undefined) { - this.timeoutIds.push( - setTimeout(() => { - this.stop(); - }, forDuration * 1000) - ); + this.durationLimitMillis = forDuration * 1000; + } else if (!wasPaused || atTime !== undefined) { + // A bare record() resumes the current capture and keeps its limit. Starting a + // new capture without forDuration, including record({ atTime }), clears it. + this.durationLimitMillis = null; } + + if ( + wasPaused && + this.durationLimitMillis !== null && + this.getAudioRecorderDurationMillis() >= this.durationLimitMillis + ) { + this.stop(); + return; + } + + this.startActualRecording(); + this.scheduleRecordingStop(); } private startActualRecording(): void { @@ -126,7 +138,8 @@ export class AudioRecorderWeb ); } - this.mediaRecorder?.pause(); + this.clearTimeouts(); + this.mediaRecorder.pause(); } recordForDuration(seconds: number): void { @@ -151,6 +164,9 @@ export class AudioRecorderWeb ); } + this.clearTimeouts(); + this.durationLimitMillis = null; + const dataPromise = new Promise((resolve) => this.mediaRecorder?.addEventListener('dataavailable', (e) => resolve(e.data)) ); @@ -174,6 +190,23 @@ export class AudioRecorderWeb clearTimeouts() { this.timeoutIds.forEach((id) => clearTimeout(id)); + this.timeoutIds = []; + } + + private scheduleRecordingStop() { + if (this.durationLimitMillis === null) { + return; + } + + const remainingDuration = Math.max( + 0, + this.durationLimitMillis - this.getAudioRecorderDurationMillis() + ); + this.timeoutIds.push( + setTimeout(() => { + this.stop(); + }, remainingDuration) + ); } private async createMediaRecorder( @@ -252,6 +285,8 @@ export class AudioRecorderWeb }); mediaRecorder?.addEventListener('stop', () => { + this.clearTimeouts(); + this.durationLimitMillis = null; this.currentTime = 0; this.mediaRecorderIsRecording = false; this.stream = null; diff --git a/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts b/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts index b42a62e023fed3..c7b6cf9c425cd8 100644 --- a/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts +++ b/packages/expo-audio/src/__tests__/AudioRecorderWeb-test.ts @@ -87,3 +87,82 @@ describe('AudioRecorderWeb fileSize', () => { expect(recorder.getStatus().fileSize).toBe(0); }); }); + +describe('AudioRecorderWeb duration limit', () => { + beforeEach(() => { + mockMediaDevices(); + jest.useFakeTimers(); + jest.setSystemTime(1000); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.useRealTimers(); + }); + + it('counts recorded time and preserves the limit across a bare resume', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + + jest.advanceTimersByTime(20000); + expect(recorder.getStatus().durationMillis).toBe(3000); + expect(stopSpy).not.toHaveBeenCalled(); + + recorder.record(); + jest.advanceTimersByTime(6999); + expect(stopSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('treats a new forDuration value as an absolute recording limit', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ forDuration: 5 }); + + jest.advanceTimersByTime(1999); + expect(stopSpy).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('clears a previous limit when arming without a duration', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ atTime: 1 }); + jest.advanceTimersByTime(20000); + + expect(stopSpy).not.toHaveBeenCalled(); + }); + + it('stops instead of resuming when a replacement limit is already spent', async () => { + const recorder = new AudioRecorderWeb({}); + await recorder.prepareToRecordAsync(); + const stopSpy = jest.spyOn(recorder, 'stop').mockResolvedValue(undefined); + + recorder.record({ forDuration: 10 }); + jest.advanceTimersByTime(3000); + recorder.pause(); + recorder.record({ forDuration: 2 }); + + expect(stopSpy).toHaveBeenCalledTimes(1); + expect(recorder.isRecording).toBe(false); + }); +}); diff --git a/packages/expo-file-system/CHANGELOG.md b/packages/expo-file-system/CHANGELOG.md index 0c7a4f27309555..4e72af0f16fa68 100644 --- a/packages/expo-file-system/CHANGELOG.md +++ b/packages/expo-file-system/CHANGELOG.md @@ -23,6 +23,7 @@ - Fixed potential file offset races when asynchronous and synchronous `FileHandle` operations overlap on Android and iOS. ([#47945](https://github.com/expo/expo/pull/47945) by [@wh201906](https://github.com/wh201906)) - Fixed `readAsStringAsync` to respect `position` and `length` when reading UTF-8 strings. ([#20291](https://github.com/expo/expo/issues/20291) by [@mvincentong](https://github.com/mvincentong)) ([#45714](https://github.com/expo/expo/pull/45714) by [@mvincentong](https://github.com/mvincentong)) - [android] Fixed `rename()` storing an unencoded URI, so reading `.uri` afterwards threw for names containing a space. ([#48496](https://github.com/expo/expo/issues/48496) by [@yagiz2000](https://github.com/yagiz2000), [#48510](https://github.com/expo/expo/pull/48510) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) +- [android] Fixed slow uploads of SAF-backed `content://` files by resolving the request body's content length once, instead of querying it through `ContentResolver` for every 8 KiB written. ([#49206](https://github.com/expo/expo/pull/49206) by [@gmaclennan](https://github.com/gmaclennan)) ### 💡 Others diff --git a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt index d58a1320c85b11..90e9a68228ad2f 100644 --- a/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt +++ b/packages/expo-file-system/android/src/main/java/expo/modules/filesystem/FileSystemUploadTask.kt @@ -226,12 +226,16 @@ private class CountingRequestBody( private val requestBody: RequestBody, private val progressListener: (Long, Long) -> Unit ) : RequestBody() { + // Resolved once: contentLength() is a ContentResolver query for SAF-backed + // files, and the counting sink needs the total on every segment written. + private val cachedContentLength: Long by lazy { requestBody.contentLength() } + override fun contentType() = requestBody.contentType() - override fun contentLength() = requestBody.contentLength() + override fun contentLength() = cachedContentLength override fun writeTo(sink: BufferedSink) { - val countingSink = CountingSink(sink, this, progressListener) + val countingSink = CountingSink(sink, cachedContentLength, progressListener) val bufferedSink = countingSink.buffer() requestBody.writeTo(bufferedSink) bufferedSink.flush() @@ -243,7 +247,7 @@ private class CountingRequestBody( */ private class CountingSink( sink: Sink, - private val requestBody: RequestBody, + private val totalBytes: Long, private val progressListener: (Long, Long) -> Unit ) : ForwardingSink(sink) { private var bytesWritten = 0L @@ -251,14 +255,19 @@ private class CountingSink( override fun write(source: Buffer, byteCount: Long) { super.write(source, byteCount) bytesWritten += byteCount - progressListener(bytesWritten, requestBody.contentLength()) + progressListener(bytesWritten, totalBytes) } } private fun UnifiedFileInterface.asRequestBody(contentType: String?): RequestBody { return object : RequestBody() { + // length() is a ContentResolver query for content:// files, and ContentProviderFile + // and AssetFile can fall back to reading the whole stream; resolve it once, and + // not before OkHttp asks for it. + private val resolvedLength: Long by lazy { length() } + override fun contentType() = contentType?.toMediaTypeOrNull() - override fun contentLength() = length() + override fun contentLength() = resolvedLength override fun writeTo(sink: BufferedSink) { inputStream().use { input -> val source = input.source() diff --git a/packages/expo-image-picker/CHANGELOG.md b/packages/expo-image-picker/CHANGELOG.md index ce49c6a035a224..53e522d4b23964 100644 --- a/packages/expo-image-picker/CHANGELOG.md +++ b/packages/expo-image-picker/CHANGELOG.md @@ -17,6 +17,7 @@ - [iOS] Fixed crop square detection using the main screen's scale instead of the scale of the view being measured. ([#48172](https://github.com/expo/expo/pull/48172) by [@alanjhughes](https://github.com/alanjhughes)) - [iOS] Fix a failed crop resolving with the uncropped original image instead of rejecting when `allowsEditing` is `true`. ([#48524](https://github.com/expo/expo/issues/48524) by [@aashishshrestha5532](https://github.com/aashishshrestha5532), [#48541](https://github.com/expo/expo/pull/48541) by [@intergalacticspacehighway](https://github.com/intergalacticspacehighway)) - [iOS] Fix the video `PHAssetResourceManager` fast path rejecting instead of falling back to the slower path that can fetch iCloud assets, and pass `shouldDownloadFromNetwork` through when reading Live Photo resources. As a result, a video whose data is not stored locally is now downloaded through the fallback path even when `shouldDownloadFromNetwork` is `false` and `videoExportPreset` is `Passthrough`. ([#48658](https://github.com/expo/expo/issues/48658) by [@gcampoyf-cloud](https://github.com/gcampoyf-cloud), [#48794](https://github.com/expo/expo/pull/48794) by [@expo-bot](https://github.com/expo-bot)) +- [iOS] Fix picking a video terminating apps that ship no `NSPhotoLibraryUsageDescription` by only taking the `PHAssetResourceManager` fast path when photo library read access has already been granted. ([#49362](https://github.com/expo/expo/pull/49362) by [@OlegBezr](https://github.com/OlegBezr)) ### 💡 Others diff --git a/packages/expo-image-picker/ios/MediaHandler.swift b/packages/expo-image-picker/ios/MediaHandler.swift index b83260ca46ab52..1e64460ea1a313 100644 --- a/packages/expo-image-picker/ios/MediaHandler.swift +++ b/packages/expo-image-picker/ios/MediaHandler.swift @@ -420,7 +420,10 @@ internal struct MediaHandler { // asset as *adjusted* and will re-render a temporary file for us. Copying the resource bytes // ourselves is dramatically faster because it just streams the already-existing file. - if options.videoExportPreset == .passthrough, let assetId = selectedVideo.assetIdentifier { + let photoLibraryReadStatus = PHPhotoLibrary.authorizationStatus(for: .readWrite) + let hasPhotoLibraryReadAccess = photoLibraryReadStatus == .authorized || photoLibraryReadStatus == .limited + + if options.videoExportPreset == .passthrough, hasPhotoLibraryReadAccess, let assetId = selectedVideo.assetIdentifier { let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil) if let asset = fetchResult.firstObject { // Prefer the full-size resource when available, otherwise fall back to the default `.video`. diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 32b5c3290f8829..abdb00174e49c6 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -78,6 +78,8 @@ ### 💡 Others +- Derive `useIsFocused` from context. ([#49390](https://github.com/expo/expo/pull/49390) by [@Ubax](https://github.com/Ubax)) +- Base `useNavigationState` on global state. ([#49381](https://github.com/expo/expo/pull/49381) by [@jakub-agent](https://github.com/jakub-agent)) - Move Expo Router store values into React context. ([#49218](https://github.com/expo/expo/pull/49218) by [@Ubax](https://github.com/Ubax)) - Remove the ignored `linking.enabled` option. ([#49103](https://github.com/expo/expo/pull/49103) by [@Ubax](https://github.com/Ubax)) - Build the complete initial navigation state from a deep link. ([#49297](https://github.com/expo/expo/pull/49297) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx index 6c370886f53f7b..e949248bf9114a 100644 --- a/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx +++ b/packages/expo-router/src/native-tabs/__tests__/render.test.ios.tsx @@ -292,11 +292,11 @@ describe('First focused tab', () => { expect(screen.getByTestId('first')).toBeVisible(); expect(screen.getByTestId('second')).toBeVisible(); - // TODO(@ubax): rework the ROUTE_NAMES_CHANGED and change this back to 4 - expect(TabsScreen).toHaveBeenCalledTimes(6); + // TODO(@ubax): when ROUTE_NAMES_CHANGED is reworked check if this can be reduced + expect(TabsScreen).toHaveBeenCalledTimes(4); expect(TabsScreen.mock.calls[0][0].screenKey).toBe('first'); expect(TabsScreen.mock.calls[1][0].screenKey).toBe('second'); - expect(TabsHost).toHaveBeenCalledTimes(3); + expect(TabsHost).toHaveBeenCalledTimes(2); expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('second'); }); @@ -322,10 +322,10 @@ describe('First focused tab', () => { expect(screen.getByTestId('first')).toBeVisible(); expect(screen.getByTestId('second')).toBeVisible(); - expect(TabsScreen).toHaveBeenCalledTimes(6); + expect(TabsScreen).toHaveBeenCalledTimes(4); expect(TabsScreen.mock.calls[0][0].screenKey).toBe('first'); expect(TabsScreen.mock.calls[1][0].screenKey).toBe('second'); - expect(TabsHost).toHaveBeenCalledTimes(3); + expect(TabsHost).toHaveBeenCalledTimes(2); expect(TabsHost.mock.calls[0][0].navStateRequest.selectedScreenKey).toBe('second'); }); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx index 4fc891c20991ae..8bd065e6a0fc8c 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useIsFocused.test.ios.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import type { ParamListBase } from '../../routers'; import { Screen } from '../Screen'; import { createNavigationContainerRef } from '../createNavigationContainerRef'; -import { useIsFocused } from '../useIsFocused'; +import { IsFocusedContext, useIsFocused } from '../useIsFocused'; import { useNavigationBuilder } from '../useNavigationBuilder'; import { useRoute } from '../useRoute'; import { BaseNavigationContainer } from './__fixtures__/BaseNavigationContainer'; @@ -14,6 +14,33 @@ beforeEach(() => { MockRouterKey.current = 0; }); +test('uses the focus context without a navigation object', () => { + const Test = () => { + const isFocused = useIsFocused(); + + return <>{isFocused ? 'focused' : 'unfocused'}; + }; + + const root = render( + + + + ); + + expect(root).toMatchInlineSnapshot(`"focused"`); +}); + +test('throws without a focus context', () => { + const Test = () => { + useIsFocused(); + return null; + }; + + expect(() => render()).toThrow( + "Couldn't find a focus context. Make sure the component is rendered inside your app's route tree. This is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues." + ); +}); + test('renders correct focus state', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx index 8e3bc4b6b3e748..2d1e35fe5ebf4d 100644 --- a/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx +++ b/packages/expo-router/src/react-navigation/core/__tests__/useNavigationState.test.ios.tsx @@ -125,6 +125,106 @@ test('gets the current navigation state with selector', () => { expect(callback.mock.calls[3]![0]).toBe(1); }); +test('updates a memoized consumer', () => { + const TestNavigator = (props: any): any => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + + const callback = jest.fn(); + + const Test = React.memo(() => { + callback(useNavigationState((state) => state.index)); + + return null; + }); + + const navigation = React.createRef(); + + render( + + + + {() => null} + + + ); + + expect(callback).toHaveBeenLastCalledWith(0); + + act(() => navigation.current.navigate('second')); + + expect(callback).toHaveBeenLastCalledWith(1); +}); + +test('keeps filtered state stable when the container rerenders', () => { + const TestRouter = (options: any) => { + const router = MockRouter(options); + + return { + ...router, + getStateForAction(state: NavigationState, action: any) { + if (action.type === 'ROUTE_NAMES_CHANGED') { + return null; + } + + return router.getStateForAction(state, action, options); + }, + }; + }; + + const TestNavigator = (props: any): any => { + const { state, descriptors, NavigationContent } = useNavigationBuilder(TestRouter, props); + + return ( + + {state.routes.map((route) => descriptors[route.key]!.render())} + + ); + }; + + const callback = jest.fn(); + + const Test = React.memo(() => { + callback(useNavigationState((state) => state)); + + return null; + }); + + const initialState = { + stale: false as const, + routeKeySeq: 0, + key: 'root', + index: 0, + routeNames: ['first', 'hidden'], + routes: [ + { key: 'first-0', name: 'first' }, + { key: 'hidden-0', name: 'hidden' }, + ], + }; + + const App = (_props: { value: string }) => ( + + + + + + ); + + const root = render(); + + expect(callback).toHaveBeenCalledTimes(1); + + root.update(); + + expect(callback).toHaveBeenCalledTimes(1); +}); + test('gets the correct value if selector changes', () => { const TestNavigator = (props: any): any => { const { state, descriptors, NavigationContent } = useNavigationBuilder(MockRouter, props); diff --git a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx index b3624bd82e318c..cb2984c1774721 100644 --- a/packages/expo-router/src/react-navigation/core/useIsFocused.tsx +++ b/packages/expo-router/src/react-navigation/core/useIsFocused.tsx @@ -2,8 +2,6 @@ import * as React from 'react'; import { use } from 'react'; -import { useNavigation } from './useNavigation'; - export const FocusedRouteKeyContext = React.createContext(undefined); export const IsFocusedContext = React.createContext(undefined); @@ -14,32 +12,12 @@ export const IsFocusedContext = React.createContext(undefin */ export function useIsFocused(): boolean { const isFocused = use(IsFocusedContext); - const navigation = useNavigation(); - - const isFocusedAvailable = isFocused !== undefined; - - const subscribe = React.useCallback( - (callback: () => void) => { - if (isFocusedAvailable) { - // If `isFocused` is available in context - // We don't need to subscribe to focus and blur events - return () => {}; - } - - const unsubscribeFocus = navigation.addListener('focus', callback); - const unsubscribeBlur = navigation.addListener('blur', callback); - - return () => { - unsubscribeFocus(); - unsubscribeBlur(); - }; - }, - [isFocusedAvailable, navigation] - ); - // isFocused from context only works with NavigationProvider - // So this is kept for backward compatibility - const value = React.useSyncExternalStore(subscribe, navigation.isFocused, navigation.isFocused); + if (isFocused === undefined) { + throw new Error( + "Couldn't find a focus context. Make sure the component is rendered inside your app's route tree. This is most likely a bug in expo-router. Please report it at https://github.com/expo/expo/issues." + ); + } - return isFocused ?? value; + return isFocused; } diff --git a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx index 688118cb66df12..65535dc27be5a0 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationBuilder.tsx @@ -48,7 +48,7 @@ import { FocusedRouteKeyContext } from './useIsFocused'; import { useKeyedChildListeners } from './useKeyedChildListeners'; import { useLazyValue } from './useLazyValue'; import { useNavigationHelpers } from './useNavigationHelpers'; -import { NavigationStateListenerProvider } from './useNavigationState'; +import { NavigatorStateContext } from './useNavigationState'; import { emitBeforeRemove, getPreventableRoutes, @@ -327,6 +327,9 @@ export function useNavigationBuilder< ); } + // Screen-list changes invalidate render consumers even though the reducer reads committed config. + const routeNamesKey = routeNames.join('\0'); + const { state: currentState } = use(NavigationStateContext); const { getStateForKey, resetNavigator, handleAction } = use(NavigationBuilderContext); @@ -346,7 +349,10 @@ export function useNavigationBuilder< const committedState = ( isForeignType ? resetNavigatorState(currentState, router.type) : currentState ) as State; - const state = router.getStateForDeclaredRoutes(committedState, routeNames); + const state = React.useMemo( + () => router.getStateForDeclaredRoutes(committedState, routeNames), + [committedState, routeNamesKey, router] + ); // TODO(@ubax): Check whether this ref can be safely removed. const stateKeyRef = React.useRef(committedState.key); @@ -359,8 +365,6 @@ export function useNavigationBuilder< React.useInsertionEffect(() => { registryConfigRef.current = { routeNames, routeGetIdList }; }); - // Screen-list changes invalidate render consumers even though the reducer reads committed config. - const routeNamesKey = routeNames.join('\0'); const reduce = React.useCallback( (registryState, action) => // The registry stores states from different router types; this entry only receives its own state key. @@ -587,7 +591,7 @@ export function useNavigationBuilder< return ( - + @@ -595,7 +599,7 @@ export function useNavigationBuilder< - + ); diff --git a/packages/expo-router/src/react-navigation/core/useNavigationState.tsx b/packages/expo-router/src/react-navigation/core/useNavigationState.tsx index 795b2e6eedc8fc..f588edc4258e72 100644 --- a/packages/expo-router/src/react-navigation/core/useNavigationState.tsx +++ b/packages/expo-router/src/react-navigation/core/useNavigationState.tsx @@ -1,8 +1,6 @@ 'use client'; import React, { use } from 'react'; -import useLatestCallback from '../../utils/useLatestCallback'; -import { useSyncExternalStoreWithSelector } from '../../utils/useSyncExternalStoreWithSelector'; import type { NavigationState, ParamListBase } from '../routers'; type Selector = (state: NavigationState) => T; @@ -15,67 +13,17 @@ type Selector = (state: NavigationState( selector: Selector ): T { - const stateListener = use(NavigationStateListenerContext); + const state = use(NavigatorStateContext); - if (stateListener == null) { + if (state == null) { throw new Error("Couldn't get the navigation state. Is your component inside a navigator?"); } - const value = useSyncExternalStoreWithSelector( - stateListener.subscribe, - // @ts-expect-error: this is unsafe, but needed to make the generic work - stateListener.getState, - stateListener.getState, - selector - ); - - return value; -} - -export function NavigationStateListenerProvider({ - state, - children, -}: { - state: NavigationState; - children: React.ReactNode; -}) { - const listeners = React.useRef<(() => void)[]>([]); - const stateRef = React.useRef(state); - - const getState = useLatestCallback(() => stateRef.current); - - const subscribe = useLatestCallback((callback: () => void) => { - listeners.current.push(callback); - - return () => { - listeners.current = listeners.current.filter((cb) => cb !== callback); - }; - }); - - React.useLayoutEffect(() => { - stateRef.current = state; - listeners.current.forEach((callback) => callback()); - }, [state]); - - const context = React.useMemo( - () => ({ - getState, - subscribe, - }), - [getState, subscribe] - ); - - return ( - - {children} - - ); + // TODO(@ubax): Restore selector equality bail-outs and stable result identity without a subscription. + // @ts-expect-error: this is unsafe, but needed to make the generic work + return selector(state); } -const NavigationStateListenerContext = React.createContext< - | { - getState: () => NavigationState; - subscribe: (callback: () => void) => () => void; - } - | undefined +export const NavigatorStateContext = React.createContext< + NavigationState | undefined >(undefined); diff --git a/packages/expo-screen-orientation/CHANGELOG.md b/packages/expo-screen-orientation/CHANGELOG.md index fa0c378b9e7420..0037b4ec9cf651 100644 --- a/packages/expo-screen-orientation/CHANGELOG.md +++ b/packages/expo-screen-orientation/CHANGELOG.md @@ -8,6 +8,7 @@ ### 🐛 Bug fixes +- [iOS] Fixed a main-thread deadlock when notifying screen orientation listeners. ([#49367](https://github.com/expo/expo/pull/49367) by [@ryan-saffer](https://github.com/ryan-saffer)) - [iOS] Added a development warning when the system refuses an orientation lock request, and switched to reading interface orientation from the scene's effective geometry. ([#48173](https://github.com/expo/expo/pull/48173) by [@alanjhughes](https://github.com/alanjhughes)) ### 💡 Others diff --git a/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift b/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift index e5b8d1957d4d14..a1411513c03633 100644 --- a/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift +++ b/packages/expo-screen-orientation/ios/ScreenOrientationRegistry.swift @@ -19,6 +19,7 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { var orientationControllers: [ScreenOrientationController] = [] var controllerInterfaceMasks: [ObjectIdentifier: UIInterfaceOrientationMask] = [:] private let queue = DispatchQueue(label: "expo.screenorientationregistry", attributes: .concurrent) + private let notificationQueue = DispatchQueue(label: "expo.screenorientationregistry.notifications") @objc public var currentTraitCollection: UITraitCollection? var lastOrientationMask: UIInterfaceOrientationMask @@ -204,23 +205,23 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { Called at the end of the screen orientation change. Notifies the controllers about the orientation change. */ func screenOrientationDidChange(_ newScreenOrientation: UIInterfaceOrientation) { - queue.sync(flags: .barrier) { + let controllers = queue.sync(flags: .barrier) { // Write with the barrier: if self.currentScreenOrientation != newScreenOrientation { // Only change if necessary, to prevent listeners from re-calling this method. self.currentScreenOrientation = newScreenOrientation } + return self.orientationControllers } - queue.async { - // Read without the barrier: - for controller in self.orientationControllers { + notificationQueue.async { + for controller in controllers { controller.screenOrientationDidChange(newScreenOrientation) } } } public func registerController(_ controller: ScreenOrientationController) { - queue.sync { + queue.sync(flags: .barrier) { self.orientationControllers.append(controller) } } @@ -228,7 +229,7 @@ public class ScreenOrientationRegistry: NSObject, UIApplicationDelegate { public func unregisterController(_ controller: ScreenOrientationController) { let controllerIdentifier = ObjectIdentifier(controller) - queue.sync { + queue.sync(flags: .barrier) { self.controllerInterfaceMasks.removeValue(forKey: controllerIdentifier) self.orientationControllers.removeAll(where: { $0 === controller }) } diff --git a/packages/expo-video/CHANGELOG.md b/packages/expo-video/CHANGELOG.md index 7b303e80f99e21..c8e6acce797beb 100644 --- a/packages/expo-video/CHANGELOG.md +++ b/packages/expo-video/CHANGELOG.md @@ -12,6 +12,7 @@ ### 🐛 Bug fixes +- [Android] Fix `VideoPlayer` constructor throwing `MissingActivity` when the player is created while the `Activity` is briefly unavailable. ([#48914](https://github.com/expo/expo/pull/48914) by [@huextrat](https://github.com/huextrat)) - [iOS] Fixed a data race on the video cache's open-file registry, which could crash the app while the cache was being trimmed. ([#49286](https://github.com/expo/expo/pull/49286) by [@huextrat](https://github.com/huextrat)) - [iOS] Fixed a crash when the device runs out of storage while writing to the video cache. `FileHandle.writeData:` raises an uncatchable Objective-C `NSFileHandleOperationException` on `ENOSPC`; the throwing Swift APIs are now used so the error is caught and logged instead. ([#49284](https://github.com/expo/expo/pull/49284) by [@huextrat](https://github.com/huextrat)) - [Android] Guard `PictureInPictureParams.Builder.setAutoEnterEnabled` against `NoSuchMethodError` on stock OEM firmwares that report API 31+ without shipping the method, which crashed the app from `VideoView.onLayout` even when Picture in Picture was disabled. ([#48957](https://github.com/expo/expo/pull/48957) by [@onlyshyun](https://github.com/onlyshyun)) diff --git a/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt b/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt index 0cea60098b44e3..5499879c366253 100644 --- a/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt +++ b/packages/expo-video/android/src/main/java/expo/modules/video/VideoModule.kt @@ -6,6 +6,7 @@ import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Player.REPEAT_MODE_ONE import androidx.media3.common.util.UnstableApi import expo.modules.kotlin.Promise +import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.functions.Coroutine import expo.modules.kotlin.functions.Queues import expo.modules.kotlin.modules.Module @@ -73,7 +74,8 @@ class VideoModule : Module() { Class(VideoPlayer::class) { Constructor { source: VideoSource?, /* useSynchronousReplace - iOS-only */ _: Boolean?, playerBuilderOptions: PlayerBuilderOptions? -> - val player = VideoPlayer(appContext.throwingActivity.applicationContext, appContext, source, playerBuilderOptions) + val applicationContext = appContext.reactContext?.applicationContext ?: throw Exceptions.ReactContextLost() + val player = VideoPlayer(applicationContext, appContext, source, playerBuilderOptions) appContext.mainQueue.launch { player.prepare() } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a8cc878c17554..701a225a55666d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,6 +135,9 @@ importers: expo: specifier: workspace:* version: link:../../packages/expo + expo-app-intents: + specifier: workspace:* + version: link:../../packages/expo-app-intents expo-app-metrics: specifier: workspace:* version: link:../../packages/expo-app-metrics @@ -3698,6 +3701,34 @@ importers: specifier: workspace:* version: link:../expo-module-scripts + packages/expo-app-intents: + dependencies: + react: + specifier: '*' + version: 19.2.3 + react-native: + specifier: 0.86.0 + version: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3) + devDependencies: + '@testing-library/react-native': + specifier: ^13.3.0 + version: 13.3.3(jest@29.7.0(@types/node@22.20.1)(ts-node@10.9.2(@swc/core@1.15.18)(@types/node@22.20.1)(typescript@6.0.3)))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.3))(react-test-renderer@19.2.3(react@19.2.3))(react@19.2.3) + '@types/jest': + specifier: ^29.2.1 + version: 29.5.14 + '@types/node': + specifier: ^22.14.0 + version: 22.20.1 + '@types/react': + specifier: ~19.2.0 + version: 19.2.14 + expo: + specifier: workspace:* + version: link:../expo + expo-module-scripts: + specifier: workspace:* + version: link:../expo-module-scripts + packages/expo-app-metrics: dependencies: expo-updates-interface: