本技能用于在 Expo / React Native 应用中实现正确的远程推送,基于 expo-notifications,覆盖 iOS 与 Android。核心目标:权限只在用户看到理由时申请;服务器始终持有设备当前令牌并在退出登录时移除;Android 渠道与数据种类符合与服务器写定的契约;推送点击只打开它所指的界面,不打开载荷中指定的任意路由;前台展示策略单一明确。另提供零依赖检查器和基于假端口的 Jest 测试。
主要规则
-
权限在上下文中申请,绝不在启动时申请;渠道先行。提示由用户操作触发(如 onPress 中调用 optInToPush),不放在根布局或副作用中。Android 渠道须在提示前存在;被阻止时引导到系统设置。
-
由一个适配器独占 expo-notifications 的推送 API。令牌、权限、处理器和响应调用只出现在 expo-notifications-push-adapter.ts 的 PushPort 之后,其他代码和测试使用端口或 createFakePush()。
-
令牌携带显式 EAS project id 并跟随每次变化。getExpoPushTokenAsync 从 readEasProjectId() 获取 { projectId },应用配置设置 extra.eas.projectId;addPushTokenListener 在轮换时用被替换的令牌重新注册。
-
注册单一路径、串行、幂等、有触发条件并留存记录。createPushRegistration 在启动、每次回到前台(渠道同步之后)、令牌轮换和账号变更时同步,一次一个;通过已登录 ApiClient 上的 PushRegistrar 调用,需要账号时绝不用裸 services.api(PUT /push/registrations/{token})。最近一次被接受的注册会保存(PushRecordPort、kv-store),以便用户在设置中关闭通知后首次同步时注销;失败等待下次触发,无重试定时器;服务器丢弃长期未见(270 天)的注册。
-
contracts/push.json 是与服务器的约定,列出提供方、注册是否需要账号、所有数据种类和所有渠道;PUSH_KINDS 与 PUSH_CHANNELS 必须与之一致。
-
载荷视为不可信输入。parsePushData(valibot)读取 { kind, <id> } 中的不透明 id;所有值为字符串,整条消息最多 4096 字节,不含机密或个人信息。
-
点击只打开映射表中的目标,绝不使用载荷里的路由或 URL。destinationFor 把解析后的 kind 映射为 PushDestination,应用再映射为类型化 href。
-
点击在冷启动和运行期各处理一次,位于导航器之下。启动点击读取一次并清除,监听器处理其余,每个通知 id 只处理一次;只处理或清除能解析为推送消息的点击。usePushTaps 运行在 push 根套件的效果中,位于根 Stack 旁。
-
仅一个前台处理器为远程和本地通知决策,由适配器从 foregroundPresentation 安装,立即返回 shouldShowBanner / shouldShowList(不使用已弃用的 shouldShowAlert)。
-
Android 渠道稳定、可翻译并纳入契约。每个 id 永久对应一种行为(importance 创建后固定),名称来自语言目录,插件 defaultChannel 与契约一致。
-
原生配置按变体设置;凭证由人上传,绝不进仓库。插件设置 mode(各变体声明的 APNs 环境:开发客户端用 development,发布构建用 production)、icon 和 defaultChannel;android.googleServicesFile 列出每个变体的包名;APNs 密钥和 FCM V1 服务账号密钥通过 eas credentials 交给 EAS。
-
退出登录在会话仍有效时移除注册。pushRegistrationStep 是服务器登出步骤,在 contracts/auth.json 中名为 push-registration;其 DELETE 携带该步骤自己的访问令牌,先移除已保存令牌(服务器持有的那个),再处理更新的令牌。
-
应用绝不发送推送。不使用 Expo、FCM 或 APNs 发送端点,不引入 expo-server-sdk,EXPO_PUBLIC_* 中不含推送访问令牌。
工作流程
阅读 references/architecture-and-contract.md,并向开发者确认:提供方(除非服务器已对接 APNs 和 FCM,否则用 expo)、注册是否需要账号、数据种类及各自打开什么、需要单独静音的渠道。
安装与配置:执行 npx expo install expo-notifications expo-constants,然后在 app.config.ts 中配置插件、googleServicesFile 和 extra.eas.projectId。凭证步骤需在外部账号创建密钥,交给开发者,不得创建、读取或提交密钥。
将 templates/ 下每个文件复制到其首行指定的路径(JSON 文件落在 templates/ 下对应路径),包括测试;语言目录的键按排序顺序合并。
把模板改为应用自身:push-message.ts 中的种类、push-destination.ts 中的目标、foreground-presentation.ts 中的策略、push-channels.ts 中的渠道及各语言目录键,并在 contracts/push.json 中写同样的事实;同步更新每个测试。
作为根套件接入:适配器、保存记录适配器和注册(有账号时先于会话存在,会话接收 pushRegistrationStep 并挂到已登录客户端),注册器使用已登录客户端;在 src/services/create-root-kits.ts 注册 createPushKit,它提供上下文申请所需依赖并挂载 usePushSync、轮换监听和 usePushTaps;根文件保持不变。若同时采用 local-notifications,其适配器以 installForegroundHandler: false 创建。
在价值可见处构建上下文申请:通知权限绑定到 PermissionsPort,从按压调用 usePermissionGate,并在 onAllowed 中调用 optInToPush;在 contracts/permissions.json 添加 notifications 条目,否则 check-permissions 失败。
测试:运行 npx jest --ci src/core/push src/services/push src/hooks,再运行 node ${CLAUDE_SKILL_DIR}/scripts/check-push.mjs .,修复直到 RESULT: PASS。在指定模拟器(xcrun simctl push)和仿真器上验证原生路径,再由人在一台 iPhone 和一台 Android 手机上完成设备检查清单。
完成标准:启动无提示;仅适配器调用推送 API;显式 projectId;单一注册服务并在四类时机同步;契约已填写并交给服务器;载荷用 valibot 解析;点击只经 destinationFor 打开类型化 href;启动点击读取一次并清空;只有一个 setNotificationHandler;插件按变体设置 mode、icon、defaultChannel;凭证由人上传 EAS 且仓库无密钥;需要账号时服务器登出含 push-registration;测试通过;发布前完成双平台设备检查;check-push 输出 RESULT: PASS。
适用场景:为 Expo / React Native 应用添加远程推送、需要保证令牌同步和退出注销、需要前台策略与点击路由安全、需要与后端写定推送契约的团队。
限制:资料未提供技能自身的安装命令或使用入口价格信息;令牌、渠道和账号行为依赖 EAS、APNs 与 FCM 配置,凭证需人工创建;Expo Go 在 Android 上自 SDK 53 起不支持测试推送,须用开发构建;具体测试与检查脚本需按项目路径和工具链执行。