first commit

This commit is contained in:
xingyy 2025-12-30 19:46:48 +08:00
commit acaa58f780
556 changed files with 143123 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

View File

@ -0,0 +1,506 @@
# 百度定位与地图 React Native 插件
一个专为 React Native Android 应用设计的百度定位和地图 SDK 集成插件。
## 🚀 主要功能
- 🎯 **精准定位**:获取设备当前位置,支持高精度定位
- 🔄 **单次定位(默认)与连续定位(可选)**:通过 autoLocationMode 控制,默认单次,按需开启连续
- 🗺️ **地图功能**:完整的地图显示和控制功能
- 📱 **纯原生桥接**:无需 Activity 依赖,完全通过 RN 桥接实现
- 🛠️ **TypeScript 支持**:完整的类型定义,开发更安全
- 📡 **监听驱动、无定时轮询**:遵循官方 DEMO 模式,统一由 LocationServiceManager 管理定位回调,多方订阅互不干扰
## 📦 安装方式
在你的 React Native 项目根目录执行:
```bash
yarn add file:./NativePlugins/baidu-location-react-native
```
> **注意**:本插件已内置百度地图 API Key 和必要配置,无需额外配置 AndroidManifest.xml
## 🔧 权限说明
插件会自动添加以下权限到你的应用(无需手动配置):
```xml
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
```
> **重要**Android 6.0+ 需要在运行时申请定位权限,建议使用 `react-native-permissions`
## 📖 使用指南
### 1. 基础定位功能
最简单的定位使用方式:
```javascript
import { getCurrentPosition } from 'baidu-location-react-native';
// 获取当前位置
const getLocation = async () => {
try {
const position = await getCurrentPosition();
console.log('当前位置:', {
纬度: position.latitude,
经度: position.longitude,
精度: position.accuracy + '米',
坐标系: position.coorType,
定位类型: position.locType
});
} catch (error) {
console.error('定位失败:', error.message);
}
};
```
### 2. 地图控制功能
通过 MapModule 可以程序化控制地图:
```javascript
import { MapModule } from 'baidu-location-react-native';
// 设置地图中心点
const setMapCenter = async () => {
try {
await MapModule.setMapCenter({
latitude: 39.915, // 天安门纬度
longitude: 116.404 // 天安门经度
});
console.log('地图中心点设置成功');
} catch (error) {
console.error('设置失败:', error);
}
};
// 设置地图缩放级别
const setZoom = async () => {
await MapModule.setMapZoom(15); // 缩放级别 3-21
};
// 推荐:移动中心的两种方式
// 方式 A实例级优先推荐- 直接操作具体地图实例的原生属性
// 注意:需要拿到 <BaiduMapView ref={mapRef} /> 的 ref
const moveByRef = () => {
mapRef.current?.setNativeProps({
center: [39.915, 116.404], // [纬度, 经度]
zoom: 16,
});
};
// 方式 B模块级 MapModule需要先绑定到具体实例
// 提示onMapReady 时先绑定最近的地图实例
// const tag = findNodeHandle(mapRef.current);
// await MapModule.createMapView(tag);
const moveByModule = async () => {
await MapModule.moveToLocation({
latitude: 39.915,
longitude: 116.404,
zoom: 16,
});
};
// 检查地图是否准备就绪
const checkMapReady = async () => {
const result = await MapModule.isMapReady();
console.log('地图状态:', result.isReady ? '已准备' : '未准备');
};
```
### 3. 地图视图组件
在界面中显示地图:
```javascript
import React, { useMemo, useRef } from 'react';
import { View, StyleSheet, Alert, findNodeHandle } from 'react-native';
import { BaiduMapView, MapModule } from 'baidu-location-react-native';
const MapScreen = () => {
const mapRef = useRef(null);
// 初始化中心点:仅用于初始化的受控方案(地图 ready 后可移除 center
const initialCenter = useMemo(() => [39.915, 116.404], []);
// 地图准备就绪
const handleMapReady = async () => {
console.log('地图加载完成,可以进行操作了');
// 绑定 MapModule 到具体地图实例(模块级操作前务必绑定)
const tag = findNodeHandle(mapRef.current);
if (tag) {
await MapModule.createMapView(tag);
}
// 若你传入了 center 仅用于初始化,后续可移除受控:
// setTimeout(() => setUseControlledCenter(false), 0);
};
// 收到定位信息
const handleLocationReceived = (location) => {
console.log('收到定位:', location);
Alert.alert('定位成功', `位置:${location.latitude}, ${location.longitude}`);
};
// 地图点击事件
const handleMapClick = (coordinate) => {
console.log('点击了地图:', coordinate);
};
// 地图长按事件
const handleMapLongClick = (coordinate) => {
Alert.alert('长按地图', `坐标:${coordinate.latitude}, ${coordinate.longitude}`);
};
// 示例:程序化移动到定位点
const recenter = () => {
// 实例级(推荐)
mapRef.current?.setNativeProps({ center: [39.915, 116.404], zoom: 16 });
// 或 模块级(确保已绑定)
// MapModule.moveToLocation({ latitude: 39.915, longitude: 116.404, zoom: 16 });
};
return (
<View style={styles.container}>
<BaiduMapView
ref={mapRef}
style={styles.map}
center={initialCenter} // 初始化中心点 [纬度, 经度] —— 仅用于初始化
zoom={15} // 初始缩放级别
mapType={1} // 地图类型1=普通地图2=卫星地图
autoLocation={true} // 启用自动定位(默认单次)
onMapReady={handleMapReady}
onLocationReceived={handleLocationReceived}
onMapClick={handleMapClick}
onMapLongClick={handleMapLongClick}
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
});
```
小贴士:定位按钮等 UI 建议在 RN 端自行实现,这样可以自由控制样式与行为,并可结合 getCurrentPosition 或外部定位流进行交互。默认开启 autoLocation 时为“单次定位”,若需要连续定位,请见下方示例或设置 `autoLocationMode="continuous"`
#### 连续定位模式示例
```javascript
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { BaiduMapView } from 'baidu-location-react-native';
export default function ContinuousMap() {
return (
<View style={styles.container}>
<BaiduMapView
style={styles.map}
autoLocation={true}
autoLocationMode="continuous" // 启用连续定位(地图销毁或 autoLocation 改为 false 时仅取消地图回调,不会停止全局定位服务)
/>
</View>
);
}
const styles = StyleSheet.create({ container: { flex: 1 }, map: { flex: 1 } });
```
### 4. Android定位设置状态监听
为了减少无意义事件与厂商广播风暴带来的抖动,原生实现已做如下优化:
- 仅在“状态变化”时上报事件,不重复上报相同状态
- 对系统广播做了去抖(约 250ms同一时段合并上报
- 反注册时清理去抖任务与释放 receiver
对应的 JS API
```ts
import {
startLocationSettingObserve,
stopLocationSettingObserve,
addLocationSettingChangeListener,
getLocationSettingState,
} from 'baidu-location-react-native';
// 订阅(仅 Android 生效)
const sub = addLocationSettingChangeListener((state) => {
// state: { isLocationEnabled: boolean; hasPermission: boolean; nativeTimestamp: number }
console.log('设置变化:', state);
});
// 开始监听
await startLocationSettingObserve();
// 注意:订阅后不会自动发送“首发”快照,如需初始化 UI请主动拉取一次
const initial = await getLocationSettingState();
// 结束监听
await stopLocationSettingObserve();
sub.remove();
```
> 兼容说明:若你的业务之前依赖「订阅后必定收到首发事件」来初始化,现在应改为在订阅后主动调用 `getLocationSettingState()` 获取当前值。
返回结构:
```ts
interface LocationSettingState {
isLocationEnabled: boolean; // 系统定位开关是否开启
hasPermission: boolean; // 是否已授予任一定位权限fine/coarse
nativeTimestamp: number; // 原生生成时间戳ms
}
```
### 5. TypeScript 使用方式
完整的 TypeScript 支持:
```typescript
import React from 'react';
import { View, StyleSheet } from 'react-native';
import {
getCurrentPosition,
Position,
MapModule,
MapModuleInterface,
BaiduMapView,
BaiduMapViewProps
} from 'baidu-location-react-native';
// 定位函数
const getLocation = async (): Promise<Position> => {
try {
const position = await getCurrentPosition();
return position;
} catch (error: any) {
throw new Error(`定位失败: ${error.message}`);
}
};
// 地图组件
interface MapScreenProps {}
const MapScreen: React.FC<MapScreenProps> = () => {
const handleLocationReceived = (location: Position) => {
console.log('收到定位信息:', location);
};
const handleMapClick = (coordinate: {latitude: number; longitude: number}) => {
console.log('地图点击:', coordinate);
};
const handleMapLongClick = (coordinate: {latitude: number; longitude: number}) => {
console.log('地图长按:', coordinate);
};
return (
<View style={styles.container}>
<BaiduMapView
style={styles.map}
center={[39.915, 116.404]}
zoom={15}
mapType={1}
autoLocation={true}
// 如需连续定位额外设置autoLocationMode="continuous"
onLocationReceived={handleLocationReceived}
onMapClick={handleMapClick}
onMapLongClick={handleMapLongClick}
/>
</View>
);
};
const styles = StyleSheet.create({
container: { flex: 1 },
map: { flex: 1 },
});
```
## 📚 API 参考
### 定位服务
#### `getCurrentPosition(): Promise<Position>`
获取设备当前位置。
**返回值:** Promise成功时返回 Position 对象
```typescript
interface Position {
latitude: number; // 纬度
longitude: number; // 经度
accuracy: number; // 精度(米)
coorType?: string; // 坐标系类型gcj02、bd09ll国内默认 GCJ02海外默认 WGS84
locType: number; // 定位类型代码
direction?: number; // 定位方向(度),若可用
}
```
### 地图控制服务
#### `MapModule: MapModuleInterface`
提供程序化地图控制功能,无需 Activity 依赖。
> 使用前须知MapModule 的操作作用于“最近一次通过 createMapView 绑定的地图实例”。
> - 在没有调用 createMapView(tag) 之前,调用 setMapCenter/moveToLocation 等方法可能不会生效。
> - 推荐在 BaiduMapView 的 onMapReady 回调中,先执行绑定:
> const tag = findNodeHandle(mapRef.current); await MapModule.createMapView(tag);
> - 若你有多个地图实例,请在切换或创建新实例后再次绑定。
```typescript
interface MapModuleInterface {
// 创建地图视图(绑定作用对象)
createMapView(tag: number): Promise<{success: boolean; tag: number}>;
// 设置地图中心点(需先绑定)
setMapCenter(center: {latitude: number; longitude: number}): Promise<{success: boolean}>;
// 设置地图缩放级别3-21需先绑定
setMapZoom(zoom: number): Promise<{success: boolean}>;
// 设置地图类型1=普通2=卫星)(需先绑定)
setMapType(mapType: number): Promise<{success: boolean}>;
// 移动到指定位置(需先绑定)
moveToLocation(location: {latitude: number; longitude: number; zoom?: number}): Promise<{success: boolean; message: string}>;
// 检查地图是否准备就绪
isMapReady(): Promise<{success: boolean; isReady: boolean}>;
// 生命周期方法
onResume(): void;
onPause(): void;
onDestroy(): void;
}
```
### 地图视图组件
#### `BaiduMapView`
React Native 地图显示组件。
**属性说明:**
| 属性名 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `center` | `[number, number]` | - | 地图中心点 [纬度, 经度]。注意:中心点是受控属性,若在渲染中频繁变更其数组引用,会覆盖程序化移动;推荐仅用于初始化(地图 ready 后移除或保证引用稳定useMemo/useRef。|
| `zoom` | `number` | `15` | 地图缩放级别3-21 |
| `mapType` | `number` | `1` | 地图类型1=普通2=卫星) |
| `autoLocation` | `boolean` | `true` | 是否启用自动定位 |
| `autoLocationMode` | `'single' \| 'continuous'` | `'single'` | 自动定位模式single=单次定位continuous=连续定位 |
| `showLocationButton` | `boolean` | `true` | 是否显示定位按钮 |
| `myLocation` | `{ latitude: number; longitude: number; direction?: number; accuracy?: number }` | - | 主动推送定位蓝点(不依赖原生连续定位) |
| `onMapReady` | `() => void` | - | 地图准备就绪回调 |
| `onLocationReceived` | `(location: Position) => void` | - | 定位信息接收回调 |
| `onMapClick` | `(coordinate: {latitude: number; longitude: number}) => void` | - | 地图点击回调 |
| `onMapLongClick` | `(coordinate: {latitude: number; longitude: number}) => void` | - | 地图长按回调 |
**事件说明:**
- `onMapReady`:地图初始化完成时触发
- `onLocationReceived`:接收到定位信息时触发
- `onMapClick`:单击地图时触发
- `onMapLongClick`:长按地图时触发
## ⚙️ 系统要求
- **React Native**>= 0.64
- **Android**minSdk >= 24
- **百度地图 API Key**:已内置配置
## 🔍 常见问题
### Q: 定位失败怎么办?
A: 请检查:
1. 设备是否开启定位服务
2. 应用是否获得定位权限
3. 网络连接是否正常
### Q: 地图显示空白?
A: 请确认:
1. 网络连接正常
2. 百度地图服务可用
3. 组件样式设置了正确的宽高
### Q: 如何修改百度 API Key
A: 编辑插件目录下的 `android/src/main/AndroidManifest.xml` 文件,然后重新安装插件。
## 📄 许可证
MIT License
## 🔄 更新说明
- 地图组件 `autoLocation` 默认采用“单次定位”;如需连续定位,请在组件上传入 `autoLocationMode="continuous"`
- 定位实现已改为“监听驱动、无定时轮询”,由统一的 `LocationServiceManager` 管理
- 关闭地图或将 `autoLocation` 设为 false 仅会为地图取消定位回调,不会停止全局连续定位;`getCurrentPosition()` 为单次定位,不影响已有连续定位
- 地图 SDK 坐标类型统一为 GCJ02定位输出按区域国内 GCJ02、海外 WGS84如需调用要求 BD09LL 的地图服务,请在调用前转换为 BD09LL
---
**技术支持**:如遇问题,请检查控制台日志或联系开发团队。
### 5. 主动推送 myLocation 示例
当你不希望开启原生连续定位时,可以由 RN 侧主动获取一次位置后推送给地图,显示蓝点并带方向:
```javascript
import React, { useEffect, useState } from 'react';
import { View, StyleSheet } from 'react-native';
import { BaiduMapView, getCurrentPosition } from 'baidu-location-react-native';
export default function MyLocationMap() {
const [myLocation, setMyLocation] = useState(null);
useEffect(() => {
(async () => {
try {
const pos = await getCurrentPosition();
setMyLocation({
latitude: pos.latitude,
longitude: pos.longitude,
direction: pos.direction || 0,
accuracy: pos.accuracy,
});
} catch (e) {}
})();
}, []);
return (
<View style={styles.container}>
<BaiduMapView
style={styles.map}
center={[39.915, 116.404]}
zoom={15}
autoLocation={false}
myLocation={myLocation}
/>
</View>
);
}
const styles = StyleSheet.create({ container: { flex: 1 }, map: { flex: 1 } });
```
## 📐 坐标系与区域化说明
- 地图 SDK 坐标类型统一为 GCJ02渲染层不做坐标转换或运行时切换保证逐帧一致性
- 定位输出按区域:国内(含港澳台)默认 GCJ02海外默认 WGS84iOS 在中国范围内将 WGS84 转为 GCJ02海外保持 WGS84 原样Android 原生插件在启动前进行一次区域判断并设置坐标系
- 如需调用要求 BD09LL 的百度地图服务(例如部分 Web 服务接口),请在调用前将坐标转换为 BD09LL定位侧输出与地图渲染保持独立不互相回写或干扰

View File

@ -0,0 +1,146 @@
import { Component } from 'react';
import { ViewProps } from 'react-native';
export interface Position {
latitude: number;
longitude: number;
accuracy: number;
coorType?: string; // 坐标系类型(国内默认 GCJ02海外默认 WGS84
locType: number;
direction?: number;
}
export interface LocationSettingState {
isLocationEnabled: boolean;
hasPermission: boolean;
nativeTimestamp: number;
}
export interface BaiduMapViewProps extends ViewProps {
/**
* [latitude, longitude]
*/
center?: [number, number];
/**
* (3-21)
*/
zoom?: number;
/**
* (1: 普通地图, 2: 卫星地图)
*/
mapType?: number;
/**
*
*/
autoLocation?: boolean;
/**
* 'single' | 'continuous' 'single' autoLocation=true
*/
autoLocationMode?: 'single' | 'continuous';
/**
* RN
*/
showLocationButton?: boolean;
/**
*
*/
onMapReady?: () => void;
/**
*
*/
onLocationReceived?: (location: Position) => void;
/**
*
*/
onMapClick?: (coordinate: {latitude: number; longitude: number}) => void;
/**
*
*/
onMapLongClick?: (coordinate: {latitude: number; longitude: number}) => void;
/**
*
* latitude/longitude direction/accuracy
*/
myLocation?: { latitude: number; longitude: number; direction?: number; accuracy?: number };
}
export interface MapModuleInterface {
/**
*
* @param tag
*/
createMapView(tag: number): Promise<{success: boolean; tag: number}>;
/**
*
* @param center
*/
setMapCenter(center: {latitude: number; longitude: number}): Promise<{success: boolean}>;
/**
*
* @param zoom (3-21)
*/
setMapZoom(zoom: number): Promise<{success: boolean}>;
/**
*
* @param mapType (1: 普通地图, 2: 卫星地图)
*/
setMapType(mapType: number): Promise<{success: boolean}>;
/**
*
* @param location
*/
moveToLocation(location: {latitude: number; longitude: number; zoom?: number}): Promise<{success: boolean; message: string}>;
/**
*
*/
isMapReady(): Promise<{success: boolean; isReady: boolean}>;
/**
* -
*/
onResume(): void;
/**
* -
*/
onPause(): void;
/**
* -
*/
onDestroy(): void;
}
export function getCurrentPosition(): Promise<Position>;
// —— 定位设置状态Android ——
export function startLocationSettingObserve(): Promise<boolean>;
export function stopLocationSettingObserve(): Promise<boolean>;
export function getLocationSettingState(): Promise<LocationSettingState>;
export function addLocationSettingChangeListener(
listener: (state: LocationSettingState) => void
): { remove: () => void };
export const MapModule: MapModuleInterface;
/**
*
*/
export class BaiduMapView extends Component<BaiduMapViewProps> {}
declare const _default: {
getCurrentPosition: () => Promise<Position>;
startLocationSettingObserve: () => Promise<boolean>;
stopLocationSettingObserve: () => Promise<boolean>;
getLocationSettingState: () => Promise<LocationSettingState>;
addLocationSettingChangeListener: (listener: (state: LocationSettingState) => void) => { remove: () => void };
MapModule: MapModuleInterface;
BaiduMapView: typeof BaiduMapView;
};
export default _default;

View File

@ -0,0 +1,182 @@
import { NativeModules, requireNativeComponent, NativeEventEmitter, Platform } from 'react-native';
const { BaiduLocation, BaiduMap } = NativeModules;
/**
* 坐标系策略统一地图为 GCJ02
* - 地图SDK坐标类型统一 GCJ02渲染层不做坐标转换与运行时切换保证逐帧一致
* - 国内含港澳台定位默认 GCJ-02地图直接按 GCJ-02 渲染
* - 海外定位默认 WGS84海外 WGS84 GCJ-02在中国大陆范围外等价地图按原始坐标渲染
* - RN iOS仅在中国范围内将 WGS84 转为 GCJ-02海外保持 WGS84 原样
* - RN Android启动前尝试一次低精度定位做区域判断成功则设置原生坐标系为 GCJ-02国内 WGS84海外失败不覆盖原生默认
* - 服务调用如需调用要求 BD09LL 的地图服务请在调用前将坐标转换为 BD09LL
*/
/**
* 百度地图视图组件
*/
export const BaiduMapView = requireNativeComponent('BaiduMapView');
/**
* 获取一次定位成功后即停止
* 返回的经纬度与 coorType 按区域化策略输出国内 GCJ-02海外 WGS84
* @returns Promise<{latitude:number, longitude:number, accuracy:number, coorType?:string, locType:number}>
*/
export function getCurrentPosition() {
if (!BaiduLocation || !BaiduLocation.getCurrentPosition) {
return Promise.reject(
new Error('[BaiduLocation] native module not linked, check autolinking or react-native.config.js')
);
}
return BaiduLocation.getCurrentPosition();
}
// —— 定位设置变化监听Android——
const EVENT_LOCATION_SETTING_CHANGE = 'locationSettingChange';
const nativeObserverModule = BaiduLocation;
const emitter = nativeObserverModule ? new NativeEventEmitter(nativeObserverModule) : null;
export function startLocationSettingObserve() {
if (!BaiduLocation || !BaiduLocation.startLocationSettingObserve) {
return Promise.reject(new Error('[BaiduLocation] startLocationSettingObserve not available'));
}
return BaiduLocation.startLocationSettingObserve();
}
export function stopLocationSettingObserve() {
if (!BaiduLocation || !BaiduLocation.stopLocationSettingObserve) {
return Promise.reject(new Error('[BaiduLocation] stopLocationSettingObserve not available'));
}
return BaiduLocation.stopLocationSettingObserve();
}
export function getLocationSettingState() {
if (!BaiduLocation || !BaiduLocation.getLocationSettingState) {
return Promise.reject(new Error('[BaiduLocation] getLocationSettingState not available'));
}
return BaiduLocation.getLocationSettingState();
}
export function addLocationSettingChangeListener(listener) {
// Android-only listener guard: no-op on other platforms
if (Platform.OS !== 'android') {
return { remove() {} };
}
if (!emitter) throw new Error('[BaiduLocation] native event emitter not available');
return emitter.addListener(EVENT_LOCATION_SETTING_CHANGE, listener);
}
/**
* 地图相关功能
*/
export const MapModule = {
/**
* 创建地图视图
* @param {number} tag - 视图标签
* @returns {Promise<{success: boolean, tag: number}>}
*/
createMapView: (tag) => {
if (!BaiduMap || !BaiduMap.createMapView) {
return Promise.reject(new Error('[BaiduMap] createMapView method not available'));
}
return BaiduMap.createMapView(tag);
},
/**
* 设置地图中心点
* @param {{latitude: number, longitude: number}} center - 中心点坐标
* @returns {Promise<{success: boolean}>}
*/
setMapCenter: (center) => {
if (!BaiduMap || !BaiduMap.setMapCenter) {
return Promise.reject(new Error('[BaiduMap] setMapCenter method not available'));
}
return BaiduMap.setMapCenter(center);
},
/**
* 设置地图缩放级别
* @param {number} zoom - 缩放级别 (3-21)
* @returns {Promise<{success: boolean}>}
*/
setMapZoom: (zoom) => {
if (!BaiduMap || !BaiduMap.setMapZoom) {
return Promise.reject(new Error('[BaiduMap] setMapZoom method not available'));
}
return BaiduMap.setMapZoom(zoom);
},
/**
* 设置地图类型
* @param {number} mapType - 地图类型 (1: 普通地图, 2: 卫星地图)
* @returns {Promise<{success: boolean}>}
*/
setMapType: (mapType) => {
if (!BaiduMap || !BaiduMap.setMapType) {
return Promise.reject(new Error('[BaiduMap] setMapType method not available'));
}
return BaiduMap.setMapType(mapType);
},
/**
* 移动地图到指定位置
* @param {{latitude: number, longitude: number, zoom?: number}} location - 位置信息
* 注意地图SDK坐标类型统一为 GCJ02传入坐标按区域输出国内 GCJ-02海外 WGS84原生端直接渲染
* 涉及需 BD09LL 的服务调用请在调用前完成坐标转换
* @returns {Promise<{success: boolean, message: string}>}
*/
moveToLocation: (location) => {
if (!BaiduMap || !BaiduMap.moveToLocation) {
return Promise.reject(new Error('[BaiduMap] moveToLocation method not available'));
}
return BaiduMap.moveToLocation(location);
},
/**
* 检查地图是否准备就绪
* @returns {Promise<{success: boolean, isReady: boolean}>}
*/
isMapReady: () => {
if (!BaiduMap || !BaiduMap.isMapReady) {
return Promise.reject(new Error('[BaiduMap] isMapReady method not available'));
}
return BaiduMap.isMapReady();
},
/**
* 地图生命周期 - 恢复
*/
onResume: () => {
if (BaiduMap && BaiduMap.onResume) {
BaiduMap.onResume();
}
},
/**
* 地图生命周期 - 暂停
*/
onPause: () => {
if (BaiduMap && BaiduMap.onPause) {
BaiduMap.onPause();
}
},
/**
* 地图生命周期 - 销毁
*/
onDestroy: () => {
if (BaiduMap && BaiduMap.onDestroy) {
BaiduMap.onDestroy();
}
},
};
export default {
getCurrentPosition,
startLocationSettingObserve,
stopLocationSettingObserve,
getLocationSettingState,
addLocationSettingChangeListener,
MapModule,
BaiduMapView,
};

View File

@ -0,0 +1,29 @@
{
"name": "baidu-location-react-native",
"version": "1.0.0",
"description": "React Native Android Baidu Location (single-shot) and Map SDK integration",
"main": "index.js",
"types": "index.d.ts",
"files": [
"index.js",
"index.d.ts",
"react-native.config.js",
"android",
"plugin",
"README.md"
],
"keywords": ["react-native", "baidu", "location", "android", "map", "gps", "lbs"],
"license": "MIT",
"peerDependencies": {
"react": ">=17",
"react-native": ">=0.64"
},
"react-native": {
"android": "android"
},
"expo": {
"plugins": [
"./plugin/app.plugin.js"
]
}
}

View File

@ -0,0 +1,27 @@
const { withAndroidManifest, AndroidConfig, createRunOncePlugin } = require('@expo/config-plugins');
const AK = 'w5SWSj0Z69I9gyy3w3I4On2g3tvfYrJs';
function withBaiduAKInject(config) {
config = withAndroidManifest(config, (c) => {
try {
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(c.modResults);
const metas = Array.isArray(app['meta-data']) ? app['meta-data'] : [];
const idx = metas.findIndex((m) => m.$ && m.$['android:name'] === 'com.baidu.lbsapi.API_KEY');
if (idx >= 0) {
metas[idx].$['android:value'] = AK;
} else {
metas.push({ $: { 'android:name': 'com.baidu.lbsapi.API_KEY', 'android:value': AK } });
}
app['meta-data'] = metas;
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[baidu-location-react-native] AK inject failed:', e && e.message ? e.message : e);
}
}
return c;
});
return config;
}
module.exports = createRunOncePlugin(withBaiduAKInject, 'baidu-location-react-native-ak-injector', '1.0.0');

View File

@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
android: {
sourceDir: 'android',
},
ios: null, // 暂无 iOS
},
},
};

View File

@ -0,0 +1,129 @@
const { withMainActivity } = require('@expo/config-plugins');
/**
* Android 冷启动/后台恢复防崩溃补丁 (终极版)
*
* 问题根源
* Android 系统在内存不足杀掉应用时会保存 Activity/Fragment 状态 (savedInstanceState)
* 当用户重新打开应用时系统会尝试恢复这些状态但在 RN 混合开发或特定机型 OriginOS
* 这种恢复往往因为 View 结构改变或 Fragment 状态不一致导致崩溃 (Unable to find viewState, Fragment InstantiationException)
*
* 解决方案 (双重保险)
* 1. 入口防御onCreate: 丢弃系统传来的 savedInstanceState显式清空 Bundle并传 null super
* -> 防止 Activity 尝试恢复旧 Fragment
* 2. 出口防御onSaveInstanceState: 在应用被杀前拦截保存动作强行清空要保存的数据
* -> 确保存到磁盘里的就是空的下次启动时系统拿到的也是空的从根源上消除脏数据
*
* 历史
* - v1: super.onCreate(null)
* - v2: savedInstanceState.clear()
* - v3: 增加 onSaveInstanceState 覆盖实现不存+不取闭环
*/
const withDisableSavedState = (config) => {
return withMainActivity(config, (config) => {
let contents = config.modResults.contents;
const language = config.modResults.language; // 'java' | 'kt'
// ==============================================================================
// Part 1: 修改 onCreate (入口防御)
// ==============================================================================
// 匹配 .clear() 调用,前面可能有 savedInstanceState?. 或 savedInstanceState.
const isCreateCleared = /savedInstanceState\??\.clear\(\)/.test(contents);
// 匹配原始的 super.onCreate(savedInstanceState)
const originalCreatePattern = /super\.onCreate\(\s*savedInstanceState\s*\);?/;
// 匹配旧版补丁 super.onCreate(null)
const patchedCreatePattern = /super\.onCreate\(\s*null\s*\);?/;
const getNewCreateCode = (lang) => {
if (lang === 'kt') {
return 'savedInstanceState?.clear()\n super.onCreate(null);';
} else {
return 'if (savedInstanceState != null) savedInstanceState.clear();\n super.onCreate(null);';
}
};
if (isCreateCleared) {
// 已经是 v2 或 v3 版本,不做 onCreate 修改
} else if (contents.match(originalCreatePattern)) {
contents = contents.replace(originalCreatePattern, getNewCreateCode(language));
console.log('[disable-saved-state-plugin] ✅ [1/2] Patched onCreate: Added clear() and null.');
} else if (contents.match(patchedCreatePattern)) {
contents = contents.replace(patchedCreatePattern, getNewCreateCode(language));
console.log('[disable-saved-state-plugin] ✅ [1/2] Upgraded onCreate: Added clear().');
}
// ==============================================================================
// Part 2: 注入/修改 onSaveInstanceState (出口防御)
// ==============================================================================
// 检查是否已经存在 onSaveInstanceState 方法
// 简单检查方法名,可能不够严谨但对于 Expo 生成的模板通常够用
const hasSaveInstance = contents.includes('onSaveInstanceState');
const isSaveCleared = /outState\??\.clear\(\)/.test(contents);
if (hasSaveInstance && isSaveCleared) {
console.log('[disable-saved-state-plugin] [2/2] onSaveInstanceState is already patched.');
} else if (hasSaveInstance) {
// 如果已经有这个方法但没 clear这比较复杂可能用户自己写了逻辑。
// 为安全起见,尝试在 super.onSaveInstanceState 之后插入 clear。
// 但通常 Expo 默认模板没有这个方法。
console.warn('[disable-saved-state-plugin] ⚠️ onSaveInstanceState exists but not patched. Skipping to avoid conflict.');
} else {
// 如果没有这个方法,我们需要注入它。
// 找一个合适的位置插入,通常在 onCreate 之后,或者类结束前。
// 我们选择在 onCreate 方法闭包结束后插入。
// 假设 onCreate 结构是: override fun onCreate(...) { ... }
// 我们寻找 onCreate 的结束花括号。这很难用正则完美匹配。
//
// 替代方案:找到类定义的最后一个花括号前插入。
// 假设文件最后一行是 } (Java/Kotlin 类结束)
const lastBraceIndex = contents.lastIndexOf('}');
if (lastBraceIndex !== -1) {
const getSaveStateCode = (lang) => {
if (lang === 'kt') {
return `
/**
* 拦截状态保存防止系统在后台杀进程时写入 Fragment 状态
* 结合 onCreate 中的 clear实现双重保险
*/
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.clear()
}
`;
} else {
return `
/**
* 拦截状态保存防止系统在后台杀进程时写入 Fragment 状态
* 结合 onCreate 中的 clear实现双重保险
*/
@Override
protected void onSaveInstanceState(Bundle outState) {
super.onSaveInstanceState(outState);
outState.clear();
}
`;
}
};
const codeToInsert = getSaveStateCode(language);
// 插入到倒数第二个花括号之后(假设那是类结束符),或者直接替换最后一个花括号
// 更安全的方式:替换最后一个 '}' 为 code + '}'
contents = contents.substring(0, lastBraceIndex) + codeToInsert + contents.substring(lastBraceIndex);
console.log('[disable-saved-state-plugin] ✅ [2/2] Injected onSaveInstanceState to clear output state.');
} else {
console.error('[disable-saved-state-plugin] ❌ Failed to find class closing brace to insert onSaveInstanceState.');
}
}
config.modResults.contents = contents;
return config;
});
};
module.exports = withDisableSavedState;

View File

@ -0,0 +1,224 @@
# 极光推送 Expo 插件使用手册jpush-expo-plugin
> 本插件在 Expo React Native 项目预构建阶段自动完成 JPush 集成的关键原生配置,覆盖 Android 与 iOS 两端。文档风格与仓库 `docs` 目录保持一致,便于团队协作与交付。
## 目录
- [适用范围](#适用范围)
- [功能概览](#功能概览)
- [文件结构](#文件结构)
- [环境变量](#环境变量)
- [接入步骤](#接入步骤)
- [工作原理](#工作原理)
- [Android 注入详情](#android-注入详情)
- [iOS 注入详情](#ios-注入详情)
- [JS 侧使用示例](#js-侧使用示例)
- [构建与运行](#构建与运行)
- [常见问题](#常见问题)
- [注意事项](#注意事项)
- [验证检查清单](#验证检查清单)
## 适用范围
- 管理模式Expo 管理工作流Managed或预构建后裸工作流Prebuild → Bare
- 平台Android 与 iOSiOS 需使用自定义开发客户端或原生构建包)
- 依赖:`jpush-react-native` 与 `jcore-react-native` 必须已安装(本项目 `package.json` 已包含)
## 功能概览
- Android
- 自动在 `settings.gradle` 引入 `jpush-react-native``jcore-react-native`
- 自动添加华为/Honor 仓库源;存在 `agconnect-services.json` 时注入 `agcp` classpath 并在 `app/build.gradle` 应用插件
- 存在 `google-services.json` 时注入 `google-services` classpath 并在 `app/build.gradle` 应用插件
- 注入 JPush 厂商通道依赖小米、OPPO、VIVO、Honor、魅族、华为、FCM
- 添加 `manifestPlaceholders`JPush AppKey、渠道及厂商参数
- 修改 `AndroidManifest.xml`:添加 `JPUSH_APPKEY``JPUSH_CHANNEL``meta-data`,以及必要权限
- 自动复制 `agconnect-services.json`、`google-services.json` 到 `android/app/`
- 若存在 `google-services.json`,复制通知小图标至各 `drawable-*`,并设置 FCM 默认通知图标
- iOS
- `Entitlements.plist` 注入 `aps-environment`(默认为 `development`
- Swift `AppDelegate` 自动导入 `UserNotifications`、实现 APNs token 转发至 `JPUSHService`、遵循 `UNUserNotificationCenterDelegate`
- 针对已有 JS 事件桥接实现兼容性修正,发射注册成功/失败与远程通知事件
- 自动在 Swift Bridging Header 追加 `#import <JPUSHService.h>`
## 文件结构
```
NativePlugins/jpush-expo-plugin/
├── app.plugin.js # Expo 配置插件主体
├── small-icons/ # 通知小图标(复制到 res/drawable-*
│ ├── mdpi24.png # -> drawable-mdpi/ic_stat_notification.png
│ ├── hdpi36.png # -> drawable-hdpi/ic_stat_notification.png
│ ├── xhdpi48.png # -> drawable-xhdpi/ic_stat_notification.png
│ ├── xxhdpi72.png # -> drawable-xxhdpi/ic_stat_notification.png
│ └── xxxhdpi96.png # -> drawable-xxxhdpi/ic_stat_notification.png
└── third-push/
├── agconnect-services.json # 华为推送配置(可选)
├── google-services.json # Google/Firebase 配置(可选)
└── fieelink-fcf17-739131dc9b6e.json # 内部用途文件(如不使用可忽略)
```
## 环境变量
`envs/.env.*` 中配置以下变量Android 厂商参数可按需启用):
- 基础:
- `EXPO_PUBLIC_JPUSH_APPKEY_ANDROID`:极光推送 AppKey (Android端)
- `EXPO_PUBLIC_JPUSH_APPKEY`:极光推送 AppKey
- `EXPO_PUBLIC_JPUSH_CHANNEL`:渠道(如 `prod` / `dev`
- 厂商通道:
- `EXPO_PUBLIC_JPUSH_MI_APPID`、`EXPO_PUBLIC_JPUSH_MI_APPKEY`
- `EXPO_PUBLIC_JPUSH_OPPO_APPID`、`EXPO_PUBLIC_JPUSH_OPPO_APPKEY`、`EXPO_PUBLIC_JPUSH_OPPO_APPSECRET`
- `EXPO_PUBLIC_JPUSH_VIVO_APPID`、`EXPO_PUBLIC_JPUSH_VIVO_APPKEY`
- `EXPO_PUBLIC_JPUSH_MEIZU_APPID`、`EXPO_PUBLIC_JPUSH_MEIZU_APPKEY`(插件会自动加上 `MZ-` 前缀)
- `EXPO_PUBLIC_JPUSH_HONOR_APPID`
说明:插件优先读环境变量,其次 `app.config.js``expo.extra`,最后读取插件入参 `props``jpushAppKey`、`jpushChannel`)。
## 接入步骤
1. 在 `app.config.js` 注册插件:
```js
plugins: [
// ... 其他插件
[
"./NativePlugins/jpush-expo-plugin/app.plugin.js",
{
// 可选:作为环境变量的后备(不建议硬编码)
// jpushAppKey: "",
// jpushChannel: "prod"
}
]
]
```
2. 按需将第三方配置文件放入 `third-push/`
- `agconnect-services.json`(华为推送,存在时自动应用 `agconnect` 插件)
- `google-services.json`FCM存在时自动应用 `google-services` 插件并复制通知小图标)
3. 准备通知小图标:将对应密度的 PNG 放在 `small-icons/`(文件名已匹配,插件会复制为 `ic_stat_notification.png`
4. 在 `envs/.env.dev` / `envs/.env.test` / `envs/.env.prod.fiee` / `envs/.env.prod.moce` 设置 `EXPO_PUBLIC_JPUSH_*` 变量
5. 预构建以应用原生注入:
```bash
npx expo prebuild --platform android --clean
# 如需 iOS
npx expo prebuild --platform ios --clean
```
6. 构建与运行(见下文)
## 工作原理
- 读取环境变量与插件入参,生成 Android `manifestPlaceholders` 与 iOS 注入值
- 修改 Android 原生工程:
- `settings.gradle` 引入 jpush/jcore 模块
- `android/build.gradle` 添加仓库与 classpath按配置文件存在与否
- `app/build.gradle` 应用华为/Google 插件、添加依赖、注入 `manifestPlaceholders`
- 复制第三方 JSON 配置到 `android/app`,复制通知小图标到各密度的 `drawable-*`
- 修改 Manifest加入 `meta-data`、FCM 默认图标与必要权限
- 修改 iOS 原生工程:
- `Entitlements.plist` 注入 `aps-environment`
- Swift `AppDelegate` 注入 APNs token 转发、远程通知事件发射、UNUserNotificationCenterDelegate 等
- 追加 Bridging Header 引入 `JPUSHService.h`
- 幂等:多次运行会检测并避免重复写入;若已有相同片段则不重复注入
## Android 注入详情
- `settings.gradle`
- `include ':jpush-react-native'``include ':jcore-react-native'`
- `android/build.gradle`
- 仓库:`google()`、`mavenCentral()`、`maven { url "https://developer.huawei.com/repo/" }`、`maven { url "https://developer.hihonor.com/repo/" }`
- 按需 `classpath 'com.huawei.agconnect:agcp:1.9.1.301'`、`classpath 'com.google.gms:google-services:4.4.1'`
- 显式 `classpath('com.android.tools.build:gradle:8.8.2')`(华为插件校验 AGP 版本)
- `app/build.gradle`
- 末尾按需 `apply plugin: 'com.huawei.agconnect'`、`apply plugin: 'com.google.gms.google-services'`
- `defaultConfig → manifestPlaceholders``JPUSH_APPKEY`、`JPUSH_CHANNEL`、`JPUSH_PKGNAME` 及各厂商参数
- `dependencies`
- `implementation project(':jpush-react-native')`
- `implementation project(':jcore-react-native')`
- `cn.jiguang.sdk.plugin:{xiaomi,oppo,vivo,honor,meizu,huawei,fcm}:5.9.0`
- `com.huawei.hms:push:6.13.0.300`
- `AndroidManifest.xml`
- `application → meta-data``JPUSH_APPKEY`、`JPUSH_CHANNEL`
- `uses-permission``ACCESS_NETWORK_STATE`、`POST_NOTIFICATIONS`、`VIBRATE`、`READ_PHONE_STATE`、`QUERY_ALL_PACKAGES`、`GET_TASKS`、`ACCESS_WIFI_STATE`
- 若有 `google-services.json``meta-data com.google.firebase.messaging.default_notification_icon` 指向 `@drawable/ic_stat_notification`
- 资源复制
- `android/app/agconnect-services.json`、`android/app/google-services.json`
- `res/drawable-*/ic_stat_notification.png`
## iOS 注入详情
- `Entitlements.plist`:确保存在 `aps-environment`,默认 `development`
- `AppDelegate.swift`(如为 Swift
- 导入 `UserNotifications`
- `application(_:didRegisterForRemoteNotificationsWithDeviceToken:)` 转发 APNs token`JPUSHService.registerDeviceToken`
- 处理注册失败日志与事件发射、远程通知事件发射
- 使 `AppDelegate` 遵循 `UNUserNotificationCenterDelegate`(避免通知回调缺失)
- 对已有 JS 桥发射 `emitToJS` 做兼容性修正(移除不必要的 `.bridge` 访问)
- 移除可能残留的微信调用以避免未集成导致编译错误
- Bridging Header追加 `#import <JPUSHService.h>`
## JS 侧使用示例
- 初始化与监听(本仓库已提供 `hooks/push/JPushProvider.jsx` 可直接复用):
```js
import JPush from 'jpush-react-native';
JPush.init({
appKey: process.env.EXPO_PUBLIC_JPUSH_APPKEY,// 如果Android和Ios端不一致记得判断
channel: process.env.EXPO_PUBLIC_JPUSH_CHANNEL || 'dev',
production: process.env.EXPO_PUBLIC_ENV === 'prod' || process.env.EXPO_PUBLIC_ENV?.startsWith?.('prod:') ? 1 : 0,
});
JPush.addEventListener({
receiveNotification: (evt) => { console.log('[JPush] 通知:', evt); },
openNotification: (evt) => { console.log('[JPush] 点击通知:', evt); },
receiveMessage: (evt) => { console.log('[JPush] 自定义消息:', evt); },
});
```
- 若使用本仓库的 `JPushProvider`,在应用根节点包裹即可开启路由联动与事件管理。
## 构建与运行
```bash
pnpm install
# 预构建以应用原生注入
npx expo prebuild --platform android --clean
npx expo prebuild --platform ios --clean
# 运行 Android
pnpm run android -- --device
# 生产构建 Android示例
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
# iOS 构建:使用 Xcode Archive参见 docs/Expo React Native IPA 打包指南.md
```
## 常见问题
1. 构建报错 `com.huawei.agconnect`/`google-services` 插件相关?
- 确认 `third-push/` 下配置文件存在且格式正确;可删除 `android/.gradle``android/build` 后重试
- 若 AGP 版本冲突,检查根 `build.gradle``com.android.tools.build:gradle` 版本(插件会强制为 `8.8.2`,必要时按你项目统一版本调整)
2. FCM 默认通知图标找不到 `ic_stat_notification`
- 请确保 `small-icons/` 中五种密度的文件存在,预构建后图标复制到 `res/drawable-*`
3. 商店审核因权限被拒?
- `QUERY_ALL_PACKAGES`、`READ_PHONE_STATE`、`GET_TASKS` 在部分商店策略较严格,建议按产品需求评估并在发布渠道进行最小化配置
4. iOS 收不到推送/编译错误?
- 检查 `Push Notifications` 能力与 Apple 开发者证书;确认 `AppDelegate` 为 Swift 且未被其它插件覆盖
- 确认 Pod 依赖(`jpush-react-native` 安装后会引入 `JPush`/`JCore`)并执行 `cd ios && pod install --repo-update`
5. `AppKey`/厂商参数未生效?
- 查看 `app/build.gradle → defaultConfig → manifestPlaceholders` 是否包含对应条目;查看 `AndroidManifest.xml``meta-data`
## 注意事项
- 预构建会覆盖插件管理的原生片段;避免手动修改相同逻辑以免下次 `prebuild` 被回滚
- `third-push/` 下的配置文件通常包含敏感信息,请勿上传到公共仓库;若必须提交,请确保仓库访问受控
- Android 依赖版本JPush/厂商/AGCP/FCM随时间可能更新如遇兼容性问题建议按需升级并同步插件版本
- iOS 仅在 Swift `AppDelegate` 路径进行自动化注入Obj-C 项目请手动补充 APNs 回调与桥接逻辑
## 验证检查清单
- Gradle 与插件
- [ ] `settings.gradle` 包含 `:jpush-react-native``:jcore-react-native`
- [ ] `android/build.gradle` 含华为/Honor 仓库与按需 classpath 注入
- [ ] `app/build.gradle` 末尾存在按需 `apply plugin: 'com.huawei.agconnect'``apply plugin: 'com.google.gms.google-services'`
- [ ] `app/build.gradle → defaultConfig → manifestPlaceholders` 包含 `JPUSH_APPKEY/JPUSH_CHANNEL` 与厂商参数
- [ ] `dependencies` 包含 JPush 厂商插件
- 资源与文件
- [ ] `android/app/agconnect-services.json`、`android/app/google-services.json`
- [ ] `res/drawable-*/ic_stat_notification.png` 已就位
- Manifest 与权限
- [ ] `AndroidManifest.xml``meta-data` 存在 `JPUSH_APPKEY``JPUSH_CHANNEL`
- [ ] `com.google.firebase.messaging.default_notification_icon=@drawable/ic_stat_notification`(如使用 FCM
- [ ] 必要权限已添加且符合商店策略
- iOS
- [ ] `Entitlements.plist``aps-environment`
- [ ] `AppDelegate.swift` 已存在 APNs 回调与事件发射逻辑
- [ ] Bridging Header 含 `#import <JPUSHService.h>`
- 运行验证
- [ ] 设备上可收到 JPush/厂商通道推送,点击通知可正确跳转
---
如需在 CI 中按环境生成/注入第三方配置文件(而非提交到仓库),可将 `agconnect-services.json``google-services.json` 在流水线阶段写入到 `NativePlugins/jpush-expo-plugin/third-push/`,再运行 `expo prebuild` 与构建流程。

View File

@ -0,0 +1,542 @@
const {
withEntitlementsPlist,
withAppDelegate,
withDangerousMod,
withSettingsGradle,
withProjectBuildGradle,
withAppBuildGradle,
withAndroidManifest,
AndroidConfig,
IOSConfig,
withXcodeProject
} = require('@expo/config-plugins');
const path = require('path');
const fs = require('fs');
module.exports = function withJPushPlugin(config, props = {}) {
// 尝试从 .env 文件加载环境变量
try {
require('dotenv').config();
} catch (e) {
// 如果 dotenv 不可用,忽略错误
}
// 获取配置参数 - 优先从环境变量读取,然后从 config最后从 props
// 注意这里仅Android使用无需判断环境
const jpAppKey = process.env.EXPO_PUBLIC_JPUSH_APPKEY_ANDROID || config.expo?.extra?.EXPO_PUBLIC_JPUSH_APPKEY_ANDROID || props.jpushAppKey || '';
const jpChannel = process.env.EXPO_PUBLIC_JPUSH_CHANNEL || config.expo?.extra?.EXPO_PUBLIC_JPUSH_CHANNEL || props.jpushChannel || 'dev';
// 厂商通道参数
const miAppId = process.env.EXPO_PUBLIC_JPUSH_MI_APPID || '';
const miAppKey = process.env.EXPO_PUBLIC_JPUSH_MI_APPKEY || '';
const oppoAppId = process.env.EXPO_PUBLIC_JPUSH_OPPO_APPID || '';
const oppoAppKey = process.env.EXPO_PUBLIC_JPUSH_OPPO_APPKEY || '';
const oppoAppSecret = process.env.EXPO_PUBLIC_JPUSH_OPPO_APPSECRET || '';
const vivoAppId = process.env.EXPO_PUBLIC_JPUSH_VIVO_APPID || '';
const vivoAppKey = process.env.EXPO_PUBLIC_JPUSH_VIVO_APPKEY || '';
const meizuAppId = process.env.EXPO_PUBLIC_JPUSH_MEIZU_APPID || '';
const meizuAppKey = process.env.EXPO_PUBLIC_JPUSH_MEIZU_APPKEY || '';
const honorAppId = process.env.EXPO_PUBLIC_JPUSH_HONOR_APPID || '';
// iOS 配置(保持现有逻辑)
config = withEntitlementsPlist(config, (c) => {
if (!c.modResults['aps-environment']) {
c.modResults['aps-environment'] = c.ios?.entitlements?.['aps-environment'] || 'development';
}
return c;
});
config = withAppDelegate(config, (c) => {
let contents = c.modResults.contents;
const isSwift = c.modResults.language === 'swift';
const hasForward = contents.includes('JPUSHService.registerDeviceToken');
if (isSwift) {
// 导入 UserNotifications 框架
if (!contents.includes('\nimport UserNotifications')) {
if (contents.includes('import ReactAppDependencyProvider')) {
contents = contents.replace(
/(import\s+ReactAppDependencyProvider.*\n)/m,
`$1import UserNotifications\n`
);
} else if (contents.includes('\nimport React\n')) {
contents = contents.replace(
/(import\s+React\s*\n)/m,
`$1import UserNotifications\n`
);
} else {
contents = `import UserNotifications\n` + contents;
}
}
// 注入 APNs token 转发方法
if (!hasForward) {
const insertion = `\n // APNs token 转发给 JPush\n public override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {\n JPUSHService.registerDeviceToken(deviceToken)\n }\n\n public override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {\n NSLog("[JPush] APNS registration failed: %@", error.localizedDescription)\n }\n\n public override func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {\n JPUSHService.handleRemoteNotification(userInfo)\n completionHandler(.newData)\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${insertion}}\n\nclass ReactNativeDelegate:`);
}
}
// 使 AppDelegate 遵循 UNUserNotificationCenterDelegate 协议
if (!contents.includes('UNUserNotificationCenterDelegate')) {
contents = contents.replace(
/public\s+class\s+AppDelegate:\s*ExpoAppDelegate/,
'public class AppDelegate: ExpoAppDelegate, UNUserNotificationCenterDelegate'
);
}
// 添加 JS Bridge 引用属性
if (!contents.includes('weak var jsBridge: RCTBridge?')) {
if (contents.includes('weak var previousNotificationDelegate: UNUserNotificationCenterDelegate?')) {
contents = contents.replace(
'weak var previousNotificationDelegate: UNUserNotificationCenterDelegate?',
'weak var previousNotificationDelegate: UNUserNotificationCenterDelegate?\n // JS Bridge 引用\n weak var jsBridge: RCTBridge?'
);
} else if (/(var\s+reactNativeFactory:\s*(?:RCTReactNativeFactory|ExpoReactNativeFactory)\?)/.test(contents)) {
contents = contents.replace(
/(var\s+reactNativeFactory:\s*(?:RCTReactNativeFactory|ExpoReactNativeFactory)\?)/,
`$1\n\n // 保存前一个通知代理,确保事件正确转发\n weak var previousNotificationDelegate: UNUserNotificationCenterDelegate?\n // JS Bridge 引用\n weak var jsBridge: RCTBridge?`
);
} else if (/(public\s+class\s+AppDelegate:\s*ExpoAppDelegate[^\n]*\n)/.test(contents)) {
contents = contents.replace(
/(public\s+class\s+AppDelegate:\s*ExpoAppDelegate[^\n]*\n)/,
`$1 // 保存前一个通知代理,确保事件正确转发\n weak var previousNotificationDelegate: UNUserNotificationCenterDelegate?\n // JS Bridge 引用\n weak var jsBridge: RCTBridge?\n`
);
}
}
// 在 didFinishLaunchingWithOptions 中设置通知代理并订阅 JS 加载完成通知
if (!contents.includes('center.delegate = self')) {
const returnSuperRe = /return\s+super\.application\(application,\s*didFinishLaunchingWithOptions:\s*launchOptions\)/;
if (returnSuperRe.test(contents)) {
contents = contents.replace(
returnSuperRe,
`let result = super.application(application, didFinishLaunchingWithOptions: launchOptions)\n let center = UNUserNotificationCenter.current()\n previousNotificationDelegate = center.delegate\n center.delegate = self\n ensureNotificationDelegateChain()\n DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in\n self?.ensureNotificationDelegateChain()\n }\n NotificationCenter.default.addObserver(self, selector: #selector(onJavaScriptDidLoad(_:)), name: NSNotification.Name(\"RCTJavaScriptDidLoadNotification\"), object: nil)\n return result`
);
}
}
// 注入通知代理检查方法
if (!contents.includes('func ensureNotificationDelegateChain(')) {
const ensureMethod = `\n // 确保通知代理设置正确,并维护代理链\n private func ensureNotificationDelegateChain() {\n let center = UNUserNotificationCenter.current()\n let current = center.delegate\n if current == nil || (current != nil && !(current === self)) {\n previousNotificationDelegate = current\n center.delegate = self\n }\n }\n`;
const endOfAppDelegateRe2 = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe2.test(contents)) {
contents = contents.replace(endOfAppDelegateRe2, `\n${ensureMethod}}\n\nclass ReactNativeDelegate:`);
}
}
// 在应用激活时检查通知代理
if (!contents.includes('applicationDidBecomeActive(_ application: UIApplication)')) {
const method = `\n public override func applicationDidBecomeActive(_ application: UIApplication) {\n super.applicationDidBecomeActive(application)\n ensureNotificationDelegateChain()\n }\n`;
const endOfAppDelegateRe3 = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe3.test(contents)) {
contents = contents.replace(endOfAppDelegateRe3, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 如不存在,完整实现 willPresent/didReceive 两个通知代理方法(包含 emitToJS 调用与代理转发)
if (!contents.includes('userNotificationCenter(_ center: UNUserNotificationCenter, willPresent')) {
const delegateMethods = `\n // 处理前台通知展示\n public func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {\n let userInfo = notification.request.content.userInfo\n JPUSHService.handleRemoteNotification(userInfo)\n self.emitToJS(\"JPushEvent\", [\"type\": \"willPresent\", \"userInfo\": userInfo])\n\n var forwarded = false\n if let prev = previousNotificationDelegate {\n (prev as AnyObject).userNotificationCenter?(center, willPresent: notification, withCompletionHandler: { options in\n forwarded = true\n var merged = options\n if #available(iOS 14.0, *) {\n merged.insert(.banner)\n merged.insert(.list)\n } else {\n merged.insert(.alert)\n }\n merged.insert(.sound)\n merged.insert(.badge)\n completionHandler(merged)\n })\n }\n if !forwarded {\n if #available(iOS 14.0, *) {\n completionHandler([.banner, .list, .sound, .badge])\n } else {\n completionHandler([.alert, .sound, .badge])\n }\n }\n }\n\n // 处理通知用户响应\n public func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {\n let userInfo = response.notification.request.content.userInfo\n JPUSHService.handleRemoteNotification(userInfo)\n self.emitToJS(\"JPushEvent\", [\"type\": \"didReceive\", \"userInfo\": userInfo])\n\n var forwarded = false\n if let prev = previousNotificationDelegate {\n (prev as AnyObject).userNotificationCenter?(center, didReceive: response, withCompletionHandler: {\n forwarded = true\n completionHandler()\n })\n }\n if !forwarded {\n completionHandler()\n }\n }\n`;
const endOfAppDelegateRe2 = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe2.test(contents)) {
contents = contents.replace(endOfAppDelegateRe2, `\n${delegateMethods}}\n\nclass ReactNativeDelegate:`);
}
}
// 注入 emitToJS 工具方法
if (!contents.includes('func emitToJS(')) {
const method = `\n private func emitToJS(_ eventName: String, _ body: [AnyHashable: Any]) {\n let bridge = self.jsBridge ?? self.reactNativeFactory?.bridge\n guard let b = bridge else {\n NSLog(\"[JPush][Bridge] JS bridge not ready, skip emit: %@\", eventName)\n return\n }\n b.enqueueJSCall(\"RCTDeviceEventEmitter\", method: \"emit\", args: [eventName, body], completion: nil)\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 注入 onJavaScriptDidLoad 方法
if (!contents.includes('@objc private func onJavaScriptDidLoad(')) {
const method = `\n @objc private func onJavaScriptDidLoad(_ notification: Notification) {\n if let bridge = notification.userInfo?[\"bridge\"] as? RCTBridge {\n self.jsBridge = bridge\n NSLog(\"[JPush][Bridge] RCTBridge captured from RCTJavaScriptDidLoadNotification\")\n }\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 注入 emitToJS 工具方法
if (!contents.includes('func emitToJS(')) {
const method = `\n private func emitToJS(_ eventName: String, _ body: [AnyHashable: Any]) {\n let bridge = self.jsBridge ?? self.reactNativeFactory?.bridge\n guard let b = bridge else {\n NSLog(\"[JPush][Bridge] JS bridge not ready, skip emit: %@\", eventName)\n return\n }\n b.enqueueJSCall(\"RCTDeviceEventEmitter\", method: \"emit\", args: [eventName, body], completion: nil)\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 注入 onJavaScriptDidLoad 方法
if (!contents.includes('@objc private func onJavaScriptDidLoad(')) {
const method = `\n @objc private func onJavaScriptDidLoad(_ notification: Notification) {\n if let bridge = notification.userInfo?[\"bridge\"] as? RCTBridge {\n self.jsBridge = bridge\n NSLog(\"[JPush][Bridge] RCTBridge captured from RCTJavaScriptDidLoadNotification\")\n }\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 注入 emitToJS 工具方法
if (!contents.includes('func emitToJS(')) {
const method = `\n private func emitToJS(_ eventName: String, _ body: [AnyHashable: Any]) {\n let bridge = self.jsBridge ?? self.reactNativeFactory?.bridge\n guard let b = bridge else {\n NSLog(\"[JPush][Bridge] JS bridge not ready, skip emit: %@\", eventName)\n return\n }\n b.enqueueJSCall(\"RCTDeviceEventEmitter\", method: \"emit\", args: [eventName, body], completion: nil)\n }\n`;
const endOfAppDelegateRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe.test(contents)) {
contents = contents.replace(endOfAppDelegateRe, `\n${method}}\n\nclass ReactNativeDelegate:`);
}
}
// 在 APNs 回调中发射事件
if (contents.includes('didRegisterForRemoteNotificationsWithDeviceToken')) {
const registerMarker = 'JPUSHService.registerDeviceToken(deviceToken)';
if (contents.includes(registerMarker) && !contents.includes('type: \"apnsRegistered\"')) {
contents = contents.replace(
registerMarker,
`${registerMarker}\n let tokenHex = deviceToken.map { String(format: \"%02.2hhx\", $0) }.joined()\n self.emitToJS(\"JPushEvent\", [\"type\": \"apnsRegistered\", \"deviceToken\": tokenHex])`
);
}
}
if (contents.includes('didFailToRegisterForRemoteNotificationsWithError')) {
const failMarker = 'NSLog("[JPush] APNS registration failed:';
if (!contents.includes('type: \"apnsRegisterFailed\"')) {
contents = contents.replace(
/(didFailToRegisterForRemoteNotificationsWithError[^\{]*\{[\s\S]*?\n\s*)NSLog\(\"\[JPush\] APNS registration failed:\%.*\)\n/,
`$1NSLog(\"[JPush] APNS registration failed: \\((error as NSError).localizedDescription)\")\n self.emitToJS(\"JPushEvent\", [\"type\": \"apnsRegisterFailed\", \"error\": error.localizedDescription])\n`
);
}
}
// 在 didReceiveRemoteNotification 中发射事件
if (contents.includes('didReceiveRemoteNotification userInfo')) {
const remoteMarker = 'JPUSHService.handleRemoteNotification(userInfo)';
if (contents.includes(remoteMarker) && !contents.includes('type: \"remoteNotification\"')) {
contents = contents.replace(
remoteMarker,
`${remoteMarker}\n self.emitToJS(\"JPushEvent\", [\"type\": \"remoteNotification\", \"userInfo\": userInfo])`
);
}
}
// 移除可能导致编译错误的 WXApi.handleOpen 调用若项目未集成微信SDK
if (contents.includes('WXApi.handleOpen')) {
contents = contents.replace('if WXApi.handleOpen(url, delegate: nil) { return true }', '');
}
}
// 对已有 emitToJS 进行兼容性修正:如 reactNativeFactory 为 RCTReactNativeFactory则移除对 .bridge 的访问避免类型不匹配
if (contents.includes('private func emitToJS(') && contents.includes('var reactNativeFactory: RCTReactNativeFactory?')) {
contents = contents.replace('let bridge = self.jsBridge ?? self.reactNativeFactory?.bridge', 'let bridge = self.jsBridge');
}
// 兼容旧版极光自定义消息回调
if (!contents.includes('networkDidReceiveMessage(')) {
const legacyMethod = `\n // 兼容旧版极光自定义消息回调,避免未实现方法导致崩溃\n @objc(networkDidReceiveMessage:)\n public func networkDidReceiveMessage(_ notification: Notification) {\n let info = (notification as NSNotification).userInfo\n NSLog("[JPush] Legacy custom message: %@", String(describing: info))\n }\n`;
const endOfAppDelegateRe4 = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (endOfAppDelegateRe4.test(contents)) {
contents = contents.replace(endOfAppDelegateRe4, `\n${legacyMethod}}\n\nclass ReactNativeDelegate:`);
}
}
c.modResults.contents = contents;
return c;
});
// 配置 Bridging Header: 导入 JPUSHService.h与 app.plugin.ios.js 保持一致)
config = withDangerousMod(config, [
"ios",
(c) => {
const fs = require('fs');
const path = require('path');
const projRoot = c.modRequest.projectRoot;
try {
const iosDir = path.join(projRoot, 'ios');
const candidates = [];
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\-Bridging-Header\.h$/.test(e.name)) candidates.push(p);
}
};
if (fs.existsSync(iosDir)) walk(iosDir);
for (const headerPath of candidates) {
try {
const content = fs.readFileSync(headerPath, 'utf8');
if (!content.includes('#import <JPUSHService.h>')) {
fs.writeFileSync(headerPath, content.replace(/\s*$/, `\n#import <JPUSHService.h>\n`));
}
} catch { }
}
} catch { }
return c;
}
]);
// Android 配置
// 1. Settings Gradle 配置
config = withSettingsGradle(config, (c) => {
const jpushInclude = `include ':jpush-react-native'
project(':jpush-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/jpush-react-native/android')`;
const jcoreInclude = `include ':jcore-react-native'
project(':jcore-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/jcore-react-native/android')`;
if (!c.modResults.contents.includes(':jpush-react-native')) {
c.modResults.contents += '\n' + jpushInclude + '\n';
}
if (!c.modResults.contents.includes(':jcore-react-native')) {
c.modResults.contents += '\n' + jcoreInclude + '\n';
}
return c;
});
// 2. Project Build Gradle 配置
config = withProjectBuildGradle(config, (c) => {
let contents = c.modResults.contents;
// 添加华为、Honor 仓库
const repositories = [
'google()',
'mavenCentral()',
'maven { url "https://developer.huawei.com/repo/" }',
'maven { url "https://developer.hihonor.com/repo/" }'
];
repositories.forEach(repo => {
if (!contents.includes(repo)) {
contents = contents.replace(
/(repositories\s*\{[^}]*)/g,
`$1\n ${repo}`
);
}
});
// 基于是否存在 agconnect-services.json 决定是否添加华为 AGCP classpath
const hwJsonPath = path.join(c.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'agconnect-services.json');
const hasHwConfig = fs.existsSync(hwJsonPath);
if (hasHwConfig && !contents.includes('com.huawei.agconnect:agcp')) {
contents = contents.replace(
/(dependencies\s*\{[^}]*)/,
`$1\n classpath 'com.huawei.agconnect:agcp:1.9.1.301'`
);
}
// 如果存在 google-services.json则添加 Google Services classpath
const gmsJsonPath = path.join(c.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'google-services.json');
const hasGmsConfig = fs.existsSync(gmsJsonPath);
if (hasGmsConfig && !contents.includes('com.google.gms:google-services')) {
contents = contents.replace(
/(dependencies\s*\{[^}]*)/,
`$1\n classpath 'com.google.gms:google-services:4.4.1'`
);
}
// 确保 AGP 在根 build.gradle 中显式声明版本(华为 agcp 插件会校验)
if (/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/.test(contents)) {
contents = contents.replace(
/classpath\(['"]com\.android\.tools\.build:gradle['"]\)/,
"classpath('com.android.tools.build:gradle:8.8.2')"
);
}
c.modResults.contents = contents;
return c;
});
// 3. App Build Gradle 配置
config = withAppBuildGradle(config, (c) => {
let contents = c.modResults.contents;
// 基于是否存在 agconnect-services.json 决定是否应用华为插件
const hwJsonPath = path.join(c.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'agconnect-services.json');
const hasHwConfig = fs.existsSync(hwJsonPath);
if (hasHwConfig && !contents.includes('com.huawei.agconnect')) {
// 按官方要求写在应用 module 的 build.gradle 文件底部
contents = contents.trimEnd() + "\napply plugin: 'com.huawei.agconnect'\n";
}
// 如果存在 google-services.json则应用 Google Services 插件
const gmsJsonPath = path.join(c.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'google-services.json');
const hasGmsConfig = fs.existsSync(gmsJsonPath);
if (hasGmsConfig && !contents.includes('com.google.gms.google-services')) {
contents = contents.trimEnd() + "\napply plugin: 'com.google.gms.google-services'\n";
}
// 添加 manifestPlaceholders
const placeholders = {
JPUSH_APPKEY: jpAppKey,
JPUSH_CHANNEL: jpChannel,
JPUSH_PKGNAME: '${applicationId}',
XIAOMI_APPID: miAppId,
XIAOMI_APPKEY: miAppKey,
OPPO_APPID: oppoAppId,
OPPO_APPKEY: oppoAppKey,
OPPO_APPSECRET: oppoAppSecret,
VIVO_APPID: vivoAppId,
VIVO_APPKEY: vivoAppKey,
MEIZU_APPID: meizuAppId ? `MZ-${meizuAppId}` : '',
MEIZU_APPKEY: meizuAppKey ? `MZ-${meizuAppKey}` : '',
HONOR_APPID: honorAppId
};
const placeholderEntries = Object.entries(placeholders)
.map(([key, value]) => ` ${key}: "${value}"`)
.join(',\n');
if (!contents.includes('manifestPlaceholders')) {
contents = contents.replace(
/(defaultConfig\s*\{[^}]*)/,
`$1\n manifestPlaceholders = [\n${placeholderEntries}\n ]`
);
}
// 添加依赖 - jpush-react-native 已包含主 SDK只需厂商插件
const jpushDependencies = `
// JPush 相关依赖
implementation project(':jpush-react-native')
implementation project(':jcore-react-native')
implementation 'cn.jiguang.sdk.plugin:xiaomi:5.9.0'
implementation 'cn.jiguang.sdk.plugin:oppo:5.9.0'
implementation 'cn.jiguang.sdk.plugin:vivo:5.9.0'
implementation 'cn.jiguang.sdk.plugin:honor:5.9.0'
// 魅族
implementation 'cn.jiguang.sdk.plugin:meizu:5.9.0'
// 华为
implementation 'cn.jiguang.sdk.plugin:huawei:5.9.0'
implementation 'com.huawei.hms:push:6.13.0.300'
// FCM
implementation 'cn.jiguang.sdk.plugin:fcm:5.9.0'`;
if (!contents.includes("implementation project(':jpush-react-native')")) {
// 在 dependencies 块的结尾添加
contents = contents.replace(
/(dependencies\s*\{[\s\S]*?)(\n\s*\})/,
`$1${jpushDependencies}\n$2`
);
}
c.modResults.contents = contents;
return c;
});
// 4. Android Manifest 配置
config = withAndroidManifest(config, (c) => {
const mainApplication = AndroidConfig.Manifest.getMainApplicationOrThrow(c.modResults);
// 添加 JPush meta-data
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApplication,
'JPUSH_APPKEY',
'${JPUSH_APPKEY}'
);
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApplication,
'JPUSH_CHANNEL',
'${JPUSH_CHANNEL}'
);
// 添加必要权限(移除 QUERY_ALL_PACKAGES 与 GET_TASKS 以满足合规)
const permissions = [
'android.permission.ACCESS_NETWORK_STATE',
'android.permission.POST_NOTIFICATIONS',
'android.permission.VIBRATE',
'android.permission.READ_PHONE_STATE',
'android.permission.ACCESS_WIFI_STATE'
];
permissions.forEach(permission => {
if (!c.modResults.manifest['uses-permission']?.find(p => p.$['android:name'] === permission)) {
if (!c.modResults.manifest['uses-permission']) {
c.modResults.manifest['uses-permission'] = [];
}
c.modResults.manifest['uses-permission'].push({
$: { 'android:name': permission }
});
}
});
// 存在 google-services.json 时,设置默认通知图标,避免缺省配置问题
if (fs.existsSync(path.join(c.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'google-services.json'))) {
AndroidConfig.Manifest.addMetaDataItemToMainApplication(
mainApplication,
'com.google.firebase.messaging.default_notification_icon',
'@drawable/ic_stat_notification'
);
}
return c;
});
// 5. 复制华为/Google 配置文件与通知图标
config = withDangerousMod(config, [
'android',
async (config) => {
// 复制华为配置文件(从 NativePlugins 路径读取)
const srcPath = path.join(config.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'agconnect-services.json');
const destPath = path.join(config.modRequest.platformProjectRoot, 'app', 'agconnect-services.json');
if (fs.existsSync(srcPath)) {
fs.copyFileSync(srcPath, destPath);
console.log('[JPush] 已复制华为配置文件到 android/app 目录');
} else {
console.warn('[JPush] 未找到华为配置文件:', srcPath);
}
// 复制 Google Services 配置文件(从 NativePlugins 路径读取)
const gmsSrcPath = path.join(config.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'third-push', 'google-services.json');
const gmsDestPath = path.join(config.modRequest.platformProjectRoot, 'app', 'google-services.json');
if (fs.existsSync(gmsSrcPath)) {
fs.copyFileSync(gmsSrcPath, gmsDestPath);
console.log('[JPush] 已复制 Google Services 配置文件到 android/app 目录');
} else {
console.warn('[JPush] 未找到 Google Services 配置文件:', gmsSrcPath);
}
// 复制通知小图标到 res/drawable-* 目录(仅当存在 google-services.json
try {
const smallIconsDir = path.join(config.modRequest.projectRoot, 'NativePlugins', 'jpush-expo-plugin', 'small-icons');
if (fs.existsSync(gmsSrcPath) && fs.existsSync(smallIconsDir)) {
const resDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'res');
const entries = [
{ file: 'mdpi24.png', dir: 'drawable-mdpi' },
{ file: 'hdpi36.png', dir: 'drawable-hdpi' },
{ file: 'xhdpi48.png', dir: 'drawable-xhdpi' },
{ file: 'xxhdpi72.png', dir: 'drawable-xxhdpi' },
{ file: 'xxxhdpi96.png', dir: 'drawable-xxxhdpi' },
];
entries.forEach(({ file, dir }) => {
const src = path.join(smallIconsDir, file);
if (fs.existsSync(src)) {
const destDir = path.join(resDir, dir);
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
const dest = path.join(destDir, 'ic_stat_notification.png');
fs.copyFileSync(src, dest);
console.log(`[JPush] 已复制通知小图标 ${file} -> ${path.relative(config.modRequest.platformProjectRoot, dest)}`);
}
});
} else {
console.warn('[JPush] 未复制通知小图标:缺少 google-services.json 或 small-icons 目录');
}
} catch (e) {
console.warn('[JPush] 复制通知小图标失败:', e?.message || e);
}
return config;
},
]);
return config;
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@ -0,0 +1 @@
{"agcgw":{"url":"connect-drcn.dbankcloud.cn","backurl":"connect-drcn.hispace.hicloud.com","websocketurl":"connect-ws-drcn.hispace.dbankcloud.cn","websocketbackurl":"connect-ws-drcn.hispace.dbankcloud.com"},"agcgw_all":{"SG":"connect-dra.dbankcloud.cn","SG_back":"connect-dra.hispace.hicloud.com","CN":"connect-drcn.dbankcloud.cn","CN_back":"connect-drcn.hispace.hicloud.com","RU":"connect-drru.hispace.dbankcloud.ru","RU_back":"connect-drru.hispace.dbankcloud.cn","DE":"connect-dre.dbankcloud.cn","DE_back":"connect-dre.hispace.hicloud.com"},"websocketgw_all":{"SG":"connect-ws-dra.hispace.dbankcloud.cn","SG_back":"connect-ws-dra.hispace.dbankcloud.com","CN":"connect-ws-drcn.hispace.dbankcloud.cn","CN_back":"connect-ws-drcn.hispace.dbankcloud.com","RU":"connect-ws-drru.hispace.dbankcloud.ru","RU_back":"connect-ws-drru.hispace.dbankcloud.cn","DE":"connect-ws-dre.hispace.dbankcloud.cn","DE_back":"connect-ws-dre.hispace.dbankcloud.com"},"client":{"cp_id":"30086000700070358","product_id":"461323198430449614","client_id":"1776751283361840768","client_secret":"41D0DC2690CAFCD0ECA4301AF1950E124F61E9CCCD15D445CB96B6A454FE17E0","project_id":"461323198430449614","app_id":"115269441","api_key":"DgEDAIXNQxRYQWjmJ/O9zebr5IgUF32BA6x0YLRR7n1fMocc+r67aHUPi89BL+xWkpudpcW4Mu/HN+84v3k54sVY6Ts3BwlG535eiw==","package_name":"com.fiee.FLink"},"oauth_client":{"client_id":"115269441","client_type":1},"app_info":{"app_id":"115269441","package_name":"com.fiee.FLink"},"service":{"analytics":{"collector_url":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn","collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn","collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com","collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn","resource_id":"p1","channel_id":""},"ml":{"mlservice_url":"ml-api-drcn.ai.dbankcloud.com,ml-api-drcn.ai.dbankcloud.cn"},"cloudstorage":{"storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg":"https://ops-dra.agcstorage.link","storage_url_de":"https://ops-dre.agcstorage.link","storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn","storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru","storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia","storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu","storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn"},"search":{"url":"https://search-drcn.cloud.huawei.com"},"edukit":{"edu_url":"edukit.cloud.huawei.com.cn","dh_url":"edukit.cloud.huawei.com.cn"}},"region":"CN","configuration_version":"3.0","appInfos":[{"package_name":"com.fiee.FLink","client":{"app_id":"115269441"},"app_info":{"package_name":"com.fiee.FLink","app_id":"115269441"},"oauth_client":{"client_type":1,"client_id":"115269441"}}]}

View File

@ -0,0 +1,13 @@
{
"type": "service_account",
"project_id": "artistapp-fcf17",
"private_key_id": "739131dc9b6e7dffa6d9a9bd733bb6d09cda1a5f",
"private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCwug0fnfPCzLRg\nNxJhLI1gGCzecn+I6np7tg5Oe4w10+/XyHuyevIwfgCB4OLEuKe62ou9LutNeUJW\np9lzngfvPA25Qr2aSI6G+AT6bUSg2tJR+gMCposIx6sT/QScvYyGXFWObiV9vDB8\ntFL2BOr4bfETJ77W4Zajo6H5q1Iygi3nXfLEI96oAm7xokHNCtEaH/P8ydjcVNLt\nXr9kLhKyzuQKJNWqoT/6JDBvd6uCmDLwChtv6uiLrtGdcWF1tNBw8KwL7Buszj5/\nYHRIWsdFQpVwIlWex+y+aM4wLwtRf1nfmXy9QD+LjgfbO/qMzMqSyYcn6Evpkuat\nAfe0SARRAgMBAAECggEAKkSNX/r8sqIYIAz76d7of0u464kyBJKkMdN0r4OwIK2s\nZw0tvJ3YMA3q3CVnx0os361mj5xZreFrIuOpSBRLbIYN+DnEtFJXOGO4eRbKO7iq\nZe3oa2lexn5O5gx1XnGdWwNZeSPR9rUcWa8dSncZpcpRxtWSOL2itEeuNVMq6SZA\nQ608O4RjJCPO3BcIw7IKQrLcOvTFo+QSLD1VwpaMwoTQi0/42fwlSIkv2HfkMHn8\nqMmmjo1PodbZ9nI7lzsUfeCYASGSA8AEFwnVimgGk6mqvTdyPR7IevvurbGUEIjX\nKUyZSM0h0n/YnzZ862+JHGh9yHQNL+RYcCuxCT/oGQKBgQDredfffiBzIzvxq1Yb\nrvOWoYaKUjMdYbJS12KB/kNiqRVfDt1SmUXd5p+ZCz4GupIZQgYY25DAl8EdFCr+\nK17GB0guCsvSAHhIbT3J4F8pYexOmBOCSVD9tRDBxQnjrETozFTEIBu4s2v5e+gP\n+lJRbkoJHIS8USGto0t7QipBnQKBgQDAIVeHov/GexEP/SvnOfcFm5sBndVQ5F3K\nG1WA+V4T2fniRPQyzbmh17OW4fafJhxwzLkxzQkSUXz3hKQ9loPfn3ahukBFqqMA\nysXZHuUaXpOGOSRaJ9Lf4kuAr5K3MitIO/KmciIO/OLS5cG/osuJoYL55q07w05G\nSHvXfawZRQKBgBTzb1CUQUDdRq0W6H9oDoY9cuIFtESN97bFMR+NxuMVVjBx+yEp\nP/0hUtvkEy24Fd2ncTFu/68TEXyL2dNpenI2HabzEPVrU0hmZ9P5YZ4l90d64ml6\nKnmYTZhf9qRKJItt+rDEhpLla9rzuk8Jn59dJ7stzWWP9mYjnEioN2hFAoGBALEl\njNjot0A/9ElzuukljtdC07a32hkNllRkw395bP6MulOgFDBLct8ATPvPOF8g+pQe\njpqWW8jiDYECcZS/lLO5Cd6wJdaWMFjonWdFjyOzE/5r7VKa6Vm4ArmSKIp03Wdq\nrp49GZ4MGO8vHGcfKN+rZWIZCJzTxPYUmurjWqypAoGBAIEFEBgNHUJ+LPtyi+lJ\nmPdX/6zB821eaq3LB+tJzNtBzXYQv+v5P6W6yxmrY2OKj0MLorxvz60vGArU+vxr\n37iydZ9nvn0WXGgGly5l/WK9NrtF4pxkFtzibXF+XtlRRtGxBGxtcx5FkG8AnMaG\nXCJSfFFqUMDSoUYIivaHlI+C\n-----END PRIVATE KEY-----\n",
"client_email": "firebase-adminsdk-pdt1x@artistapp-fcf17.iam.gserviceaccount.com",
"client_id": "112830087714114507755",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-pdt1x%40artistapp-fcf17.iam.gserviceaccount.com",
"universe_domain": "googleapis.com"
}

View File

@ -0,0 +1,67 @@
{
"project_info": {
"project_number": "105551621909",
"project_id": "artistapp-fcf17",
"storage_bucket": "artistapp-fcf17.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:105551621909:android:478404211b33aed30e431c",
"android_client_info": {
"package_name": "com.fiee.FLink"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDIQ3DYMAr9pneaC9ZUbmhQklznZGdmWGk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:105551621909:android:6fd48004154c582c0e431c",
"android_client_info": {
"package_name": "com.fonchain.artistout"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDIQ3DYMAr9pneaC9ZUbmhQklznZGdmWGk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:105551621909:android:9c7ebe130a050e180e431c",
"android_client_info": {
"package_name": "uni.UNI70C49A3"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyDIQ3DYMAr9pneaC9ZUbmhQklznZGdmWGk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@ -0,0 +1,88 @@
# live-activity-react-native 使用说明React Native / Expo
本插件用于在 iOS 上启用 Live Activities灵动岛以展示持续更新的信息并提供最小的桥接接口启动/停止基于位置监听的 Live Activity。
注意:本项目仅提供原生接入与最小 JS API默认不会在你的业务代码中自动启用。你需要根据以下步骤手动集成。
## 一、安装与配置
1) 在 app.config.js 中添加插件
- 确保已包含:
```
plugins: [
// ... 其他插件
"./NativePlugins/live-activity-react-native/plugin/app.plugin.js"
]
```
2) 运行预构建
- 执行:
```
pnpm exec expo prebuild --platform ios
```
- 插件会自动:
- 拷贝原生桥接源码到 ios/LocationActivityNative
- 拷贝 Widget 扩展源码到 ios/LocationActivityWidget
- 创建 LocationActivityWidget 扩展 Target 并加入必要的 frameworks
- 在 Info.plist 与 entitlements 中开启 Live Activities 所需配置
3) Xcode 配置(签名与能力)
- 在 Apple Developer 后台为主 App 的 App ID如 com.example.app启用 Live Activities 能力
- 重新生成并下载描述文件Provisioning Profile在 Xcode 中刷新签名
- 确认主 App 的 entitlements 包含:
```
com.apple.developer.activitykit = true
```
## 二、JS API
从包中引入:
```
import LiveActivity from 'live-activity-react-native';
```
可用方法:
- LiveActivity.isSupported(): boolean
- 判断当前设备与平台是否支持 Live Activities
- await LiveActivity.startLocationActivity(): Promise<void>
- 启动位置相关的 Live Activity示例实现
- await LiveActivity.stopLocationActivity(): Promise<void>
- 停止位置相关的 Live Activity
注意:仅在 iOS 上有效,调用前建议判断 isSupported。
## 三、示例使用
```
import LiveActivity from 'live-activity-react-native';
async function onStart() {
if (LiveActivity?.isSupported?.()) {
await LiveActivity.startLocationActivity();
}
}
async function onStop() {
if (LiveActivity?.isSupported?.()) {
await LiveActivity.stopLocationActivity();
}
}
```
## 四、常见问题
- 构建报错Provisioning profile doesn't include the com.apple.developer.activitykit entitlement
- 说明描述文件未包含 Live Activities 权限。请到 Apple Developer 后台为主 App ID 勾选 Live Activities并更新描述文件。
- Xcode 中找不到 Live Activities Capability
- 请确认 Xcode 版本 ≥ 14.1iOS Deployment Target ≥ 16.1;若仍不可见,需要在 Apple Developer 后台先为 App ID 启用该能力。
## 五、移除/禁用
- 如果暂时不想在业务中使用,只需:
- 从业务代码移除 import 与调用(例如 clockIn.jsx 中的 LiveActivity 调用)
- 保留 app.config.js 中插件项,便于后续随时启用

View File

@ -0,0 +1,49 @@
import { NativeModules, Platform } from 'react-native';
const { LiveActivityModule } = NativeModules;
export const LiveActivity = {
/**
* 开始位置监听 Live Activity
* @returns {Promise<void>}
*/
startLocationActivity: async () => {
if (Platform.OS !== 'ios') {
console.warn('Live Activities only available on iOS');
return;
}
try {
await LiveActivityModule?.startLocationActivity();
} catch (error) {
console.error('Failed to start Live Activity:', error);
}
},
/**
* 停止位置监听 Live Activity
* @returns {Promise<void>}
*/
stopLocationActivity: async () => {
if (Platform.OS !== 'ios') {
console.warn('Live Activities only available on iOS');
return;
}
try {
await LiveActivityModule?.stopLocationActivity();
} catch (error) {
console.error('Failed to stop Live Activity:', error);
}
},
/**
* 检查设备是否支持 Live Activities
* @returns {boolean}
*/
isSupported: () => {
return Platform.OS === 'ios' && LiveActivityModule != null;
}
};
export default LiveActivity;

View File

@ -0,0 +1,10 @@
{
"name": "live-activity-react-native",
"version": "0.1.0",
"main": "index.js",
"private": true,
"keywords": ["react-native", "ios", "activitykit", "live-activities"],
"expo": {
"plugins": ["./plugin/app.plugin.js"]
}
}

View File

@ -0,0 +1,32 @@
const { withInfoPlist, withEntitlementsPlist, createRunOncePlugin } = require('@expo/config-plugins');
const withLiveActivityNative = require('./withLiveActivityNative');
const withLiveActivityTarget = require('./withLiveActivityTarget');
const pkg = require('../package.json');
const withLiveActivities = (config) => {
// Info.plist: 开启 Supports Live Activities
config = withInfoPlist(config, (config) => {
config.modResults.UIBackgroundModes = Array.from(new Set([...(config.modResults.UIBackgroundModes || []), 'processing']))
config.modResults.NSSupportsLiveActivities = true;
return config;
});
// entitlements: 添加 Live Activities 能力
config = withEntitlementsPlist(config, (config) => {
config.modResults['com.apple.developer.activitykit'] = true;
// 如果未来需要推送更新 Live Activity可启用下面 entitlement
// config.modResults['com.apple.developer.usernotifications.time-sensitive'] = true;
return config;
});
// 复制原生 Swift/ObjC 源码到 iOS 目录(保持 ios 目录无人为修改;构建期复制)
config = withLiveActivityNative(config);
// 自动创建 Widget Extension target
config = withLiveActivityTarget(config);
return config;
};
module.exports = createRunOncePlugin(withLiveActivities, pkg.name, pkg.version);

View File

@ -0,0 +1,37 @@
const { withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
module.exports = function withLiveActivityNative(config) {
return withDangerousMod(config, ["ios", async (config) => {
const projectRoot = config.modRequest.projectRoot;
const iosRoot = path.join(projectRoot, 'ios');
// 源码所在目录(插件自身)
const srcDir = path.join(projectRoot, 'NativePlugins', 'live-activity-react-native', 'ios');
// 目标目录(主 App 的原生模块 与 Widget 扩展 分开存放)
const appNativeDir = path.join(iosRoot, 'LocationActivityNative');
const widgetDir = path.join(iosRoot, 'LocationActivityWidget');
fs.mkdirSync(appNativeDir, { recursive: true });
fs.mkdirSync(widgetDir, { recursive: true });
// 复制主 App 桥接代码
const appFiles = ['LiveActivityModule.swift', 'LiveActivityModule.m'];
for (const f of appFiles) {
const from = path.join(srcDir, f);
const to = path.join(appNativeDir, f);
if (fs.existsSync(from)) fs.copyFileSync(from, to);
}
// 复制 Widget 扩展代码
const widgetFiles = ['LocationActivityAttributes.swift', 'LocationActivityWidget.swift'];
for (const f of widgetFiles) {
const from = path.join(srcDir, f);
const to = path.join(widgetDir, f);
if (fs.existsSync(from)) fs.copyFileSync(from, to);
}
return config;
}]);
};

View File

@ -0,0 +1,188 @@
const { withXcodeProject } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* iOS 工程自动添加 Widget Extension target
* 用于支持 Live Activities 在灵动岛中显示
*/
module.exports = function withLiveActivityTarget(config) {
return withXcodeProject(config, (config) => {
const xcodeProject = config.modResults;
const projectRoot = config.modRequest.projectRoot;
const iosRoot = path.join(projectRoot, 'ios');
// 获取 iOS bundle identifier 用于生成 Widget 扩展的 bundle ID
const mainAppBundleId = config.ios?.bundleIdentifier || 'com.fiee.FLink';
const widgetBundleId = `${mainAppBundleId}.LocationActivityWidget`;
const targetName = 'LocationActivityWidget';
try {
// 0. 确保标准分组存在,避免 node-xcode 在 correctForPath 中对 null 取 .path 崩溃
const ensureGroup = (name) => {
try {
const g = xcodeProject.pbxGroupByName(name);
if (!g) {
xcodeProject.addPbxGroup([], name);
}
} catch (_) {
// 忽略
}
};
ensureGroup('Plugins');
ensureGroup('Resources');
ensureGroup('Frameworks');
// 1. 检查是否已经存在该 target
const nativeTargets = xcodeProject.pbxNativeTargetSection();
const existingTarget = Object.values(nativeTargets).find(
target => target && target.name === targetName
);
if (existingTarget) {
console.log(`[withLiveActivityTarget] Target "${targetName}" already exists, skipping...`);
return config;
}
// 2. 创建 Info.plist 文件内容
const infoPlistContent = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>\${PRODUCT_NAME}</string>
<key>CFBundleDisplayName</key>
<string>LocationActivityWidget</string>
<key>CFBundleIdentifier</key>
<string>\${PRODUCT_BUNDLE_IDENTIFIER}</string>
<key>CFBundleVersion</key>
<string>\${CURRENT_PROJECT_VERSION}</string>
<key>CFBundleShortVersionString</key>
<string>\${MARKETING_VERSION}</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
<key>NSExtensionPrincipalClass</key>
<string>\$(PRODUCT_MODULE_NAME).LocationActivityWidget</string>
</dict>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
</dict>
</plist>`;
// 3. 确保 Widget 扩展目录存在并创建 Info.plist
const widgetDir = path.join(iosRoot, targetName);
if (!fs.existsSync(widgetDir)) {
fs.mkdirSync(widgetDir, { recursive: true });
}
const infoPlistPath = path.join(widgetDir, `${targetName}-Info.plist`);
if (!fs.existsSync(infoPlistPath)) {
fs.writeFileSync(infoPlistPath, infoPlistContent);
}
// 4. 添加 Widget Extension target
console.log(`[withLiveActivityTarget] Adding target "${targetName}"...`);
const target = xcodeProject.addTarget(targetName, 'app_extension', targetName, widgetBundleId);
const targetUuid = target.uuid;
console.log(`[withLiveActivityTarget] targetUuid=${targetUuid}`);
// 5. 添加源文件到 target
const sourceFiles = [
'LocationActivityAttributes.swift',
'LocationActivityWidget.swift'
];
sourceFiles.forEach(fileName => {
const sourcePath = `${targetName}/${fileName}`;
const fullSourcePath = path.join(iosRoot, sourcePath);
console.log(`[withLiveActivityTarget] addSourceFile -> ${sourcePath}`);
if (fs.existsSync(fullSourcePath)) {
try {
xcodeProject.addSourceFile(sourcePath, { target: targetUuid });
} catch (e) {
console.error(`[withLiveActivityTarget] addSourceFile failed for ${sourcePath}:`, e && (e.stack || e.message || e));
}
} else {
console.warn(`[withLiveActivityTarget] Source file not found: ${fullSourcePath}`);
}
});
// 6. 添加 Info.plist 到 target
const infoPlistProjectPath = `${targetName}/${targetName}-Info.plist`;
const fullInfoPlistPath = path.join(iosRoot, infoPlistProjectPath);
console.log(`[withLiveActivityTarget] addResourceFile -> ${infoPlistProjectPath}`);
if (fs.existsSync(fullInfoPlistPath)) {
try {
xcodeProject.addResourceFile(infoPlistProjectPath, { target: targetUuid });
} catch (e) {
console.error(`[withLiveActivityTarget] addResourceFile failed for ${infoPlistProjectPath}:`, e && (e.stack || e.message || e));
}
} else {
console.warn(`[withLiveActivityTarget] Info.plist not found: ${fullInfoPlistPath}`);
}
// 7. 添加必要的 framework
try { xcodeProject.addFramework('WidgetKit.framework', { target: targetUuid }); } catch (e) { console.warn('[withLiveActivityTarget] addFramework WidgetKit failed:', e.message); }
try { xcodeProject.addFramework('SwiftUI.framework', { target: targetUuid }); } catch (e) { console.warn('[withLiveActivityTarget] addFramework SwiftUI failed:', e.message); }
try { xcodeProject.addFramework('ActivityKit.framework', { target: targetUuid }); } catch (e) { console.warn('[withLiveActivityTarget] addFramework ActivityKit failed:', e.message); }
// 8. 建立主 App 对 Widget 扩展的依赖关系
try {
const mainAppTarget = xcodeProject.getFirstTarget();
console.log(`[withLiveActivityTarget] mainAppTarget=${mainAppTarget && mainAppTarget.uuid}`);
if (mainAppTarget && mainAppTarget.uuid) {
xcodeProject.addTargetDependency(mainAppTarget.uuid, [targetUuid]);
}
} catch (e) {
console.warn('[withLiveActivityTarget] Failed to add dependency:', e && (e.stack || e.message || e));
}
// 9. 清理不合法的 undefined 字段
try {
const objects = xcodeProject.hash && xcodeProject.hash.project && xcodeProject.hash.project.objects;
const cleanRecord = (rec) => {
if (!rec || typeof rec !== 'object') return;
Object.keys(rec).forEach((k) => {
const v = rec[k];
if (v === undefined || v === 'undefined' || v === null) {
delete rec[k];
}
});
};
if (objects) {
['PBXFileReference', 'PBXGroup', 'PBXNativeTarget'].forEach((section) => {
const sec = objects[section];
if (sec && typeof sec === 'object') {
Object.keys(sec).forEach((key) => {
const entry = sec[key];
if (entry && typeof entry === 'object') {
cleanRecord(entry);
Object.keys(entry).forEach((field) => {
const val = entry[field];
if (Array.isArray(val)) {
entry[field] = val.filter((x) => x != null && x !== 'undefined');
}
});
}
});
}
});
}
console.log('[withLiveActivityTarget] Sanitized pbxproj objects to remove undefined fields');
} catch (e) {
console.warn('[withLiveActivityTarget] Sanitize pbxproj failed:', e && (e.stack || e.message || e));
}
console.log(`[withLiveActivityTarget] Successfully added Widget Extension target "${targetName}"`);
} catch (error) {
console.error('[withLiveActivityTarget] Error adding target (stack):', error && (error.stack || error.message || error));
}
return config;
});
};

View File

@ -0,0 +1,58 @@
/**
* Expo Config Plugin: manifest-permission-cleaner
* 移除合并清单中的敏感权限例如 ACCESS_BACKGROUND_LOCATION QUERY_ALL_PACKAGES
*/
const { withAndroidManifest } = require("@expo/config-plugins");
function ensureToolsNamespace(manifest) {
if (!manifest.manifest) return;
manifest.manifest.$ = manifest.manifest.$ || {};
manifest.manifest.$["xmlns:tools"] = manifest.manifest.$["xmlns:tools"] || "http://schemas.android.com/tools";
}
function removePermissions(manifest, namesToRemove) {
if (!manifest.manifest) return;
const set = new Set(namesToRemove);
const usesPermission = manifest.manifest["uses-permission"] || [];
const usesPermissionSdk23 = manifest.manifest["uses-permission-sdk-23"] || [];
// 先清理当前顶层清单里已有的权限条目
manifest.manifest["uses-permission"] = usesPermission.filter((p) => {
const name = p?.$?.["android:name"];
return !set.has(name);
});
manifest.manifest["uses-permission-sdk-23"] = usesPermissionSdk23.filter((p) => {
const name = p?.$?.["android:name"];
return !set.has(name);
});
// 加入移除占位tools:node="remove"),用于在清单合并时删除来自库 AAR 的同名权限
for (const name of set) {
manifest.manifest["uses-permission"] = manifest.manifest["uses-permission"] || [];
manifest.manifest["uses-permission"].push({
$: { "android:name": name, "tools:node": "remove" },
});
}
}
module.exports = function withManifestPermissionCleaner(config, options = {}) {
const defaultPermissions = [
"android.permission.ACCESS_BACKGROUND_LOCATION",
"android.permission.QUERY_ALL_PACKAGES",
];
const removeList = Array.isArray(options.removePermissions) && options.removePermissions.length
? options.removePermissions
: defaultPermissions;
return withAndroidManifest(config, (cfg) => {
const manifest = cfg.modResults;
ensureToolsNamespace(manifest);
removePermissions(manifest, removeList);
return cfg;
});
};

View File

@ -0,0 +1,124 @@
# notification-forwarder-plugin
轻量 iOS 原生插件,用于在 ExpoBare项目中统一、稳定地将 `UNUserNotificationCenterDelegate` 事件转发到 JS 层。它通过安全的代理包装与函数交换method swizzling实现 `willPresent` / `didReceive` 等通知事件的转发,并在冷启动期间缓存事件,待 JS Bridge 就绪后再投递,避免丢失。
> 设计上与 `jpush-expo-plugin` 互补:两者共同保障 iOS 通知事件可靠地到达 JS。事件统一为 `JPushEvent`,便于现有 `hooks/push/JPushProvider.jsx` 复用。
## 特性
- iOS 事件转发:拦截并转发 `UNUserNotificationCenterDelegate``willPresent`、`didReceive` 到 JS。
- 冷启动事件缓冲App 刚启动时的通知事件被暂存,待 `RCTBridge` 建立后自动下发。
- 安全代理链:不替换你的真实 delegate而是包装成转发链避免与第三方库冲突。
- 默认前台展示优化:在前台收到通知时,默认合并展示选项,确保声音/角标以及 iOS 14+ 的横幅/列表可见。
- Expo Bare 兼容:在 `expo prebuild` 后自动拷贝源码并注入到 Xcode 工程,无需手工配置。
## 适用场景
- 使用极光或其他推送 SDK但需要在 RN 层统一接收前台/点击通知事件。
- 项目中存在多个库设置了 `UNUserNotificationCenter.delegate`,希望“都生效、都能收到事件”。
- 希望避免冷启动期间通知事件丢失(例如从通知点击启动 App
## 目录结构
```
NativePlugins/notification-forwarder-plugin/
├─ app.plugin.js # Expo Config Plugin复制源码并注入到 Xcode
└─ ios/
└─ NotificationForwarder.m # ObjectiveC事件转发器 + RCTEventEmitter
```
## 安装与注册
1. 在 `app.config.js``plugins` 中确认注册(已存在):
```js
plugins: [
// ... 其他插件
["./NativePlugins/notification-forwarder-plugin/app.plugin.js", {}],
]
```
2. 预构建 iOS 原生工程(必要):
- `npx expo prebuild --platform ios`
3. 运行或打包:
- 开发:`npx expo run:ios`
- Xcode 打包:`npx expo run:ios` 后在 `ios/*.xcworkspace` 用 Xcode Archive。
> 本插件无任何环境变量或配置项;仅需注册并 `prebuild`
## 工作原理
- 交换 `UNUserNotificationCenter``setDelegate:` 实现,在系统设置 delegate 时插入一个“转发器”(`FIUNCenterDelegateForwarder`)。
- 转发器持有“真实代理”,并在 `willPresent`、`didReceive` 两个核心回调里:
- 采集 `userInfo` 并通过 `FIJSEventEmitter` 以事件名 `JPushEvent` 投递到 JS。
- 如果真实代理实现了对应方法,则仍调用其实现,保持原行为。
- 合并前台展示选项(声音/角标iOS 14+ 横幅/列表)。
- 通过 `RCTJavaScriptDidLoadNotification` 捕获 `RCTBridge`;在 JS 未就绪时将事件暂存于全局队列,待就绪后一并下发。
- 为防止 `UNUserNotificationCenter.delegate` 是 weak 导致转发器被释放,使用关联对象对转发器进行强引用。
## 注入细节iOS
- 复制源码:在 `expo prebuild` 时将 `ios/NotificationForwarder.m` 拷贝到 `ios/FIJPushForwarder/`
- 注入工程:将 `FIJPushForwarder/NotificationForwarder.m` 添加到主 App target 的 `Sources`
- Xcode 分组兼容:在工程中确保 `Plugins` / `Resources` / `Frameworks` 分组存在,避免旧版 `node-xcode` 插件空分组崩溃。
- 平台范围:仅 iOSAndroid 不做任何改动。
## JS 使用示例
- 直接订阅 `JPushEvent` 事件(即使未使用极光,也可复用此事件通道):
```js
import { useEffect } from 'react';
import { NativeEventEmitter, NativeModules, Platform } from 'react-native';
const emitter = new NativeEventEmitter(NativeModules.FIJSEventEmitter);
export function useNotificationForwarder(onEvent) {
useEffect(() => {
if (Platform.OS !== 'ios') return;
const sub = emitter.addListener('JPushEvent', (evt) => {
// evt: { type: 'willPresent' | 'didReceive', userInfo: object }
onEvent?.(evt);
});
return () => sub.remove();
}, [onEvent]);
}
```
- 事件格式:
- `type`: `willPresent`(前台收到)或 `didReceive`(从通知交互进入/点击)。
- `userInfo`: 推送 payload通常包含 `aps`、自定义字段等。
> 若你已在项目中使用 `hooks/push/JPushProvider.jsx`,其对 `JPushEvent` 的监听可直接收到本插件转发的事件。
## 构建与运行
- 开发:
- `npx expo prebuild --platform ios`
- `npx expo run:ios`
- 生产打包:
- `npx expo prebuild --platform ios`
- 使用 Xcode Archive`ios/*.xcworkspace`)。
## 常见问题
- 没有收到事件:
- 确认已执行 `expo prebuild --platform ios` 并重新运行。
- 在运行后的 iOS 工程中确认 `FIJPushForwarder/NotificationForwarder.m` 已被编译到主 target。
- 与第三方库冲突:
- 某些库可能在运行时多次设置 `UNUserNotificationCenter.delegate`。本插件会包装最后一次设置的真实代理,形成“转发链”,通常不会冲突。
- 如出现事件重复或缺失,检查是否有库在其代理实现中未调用完成回调或强行覆盖行为。
- 前台展示不出现横幅:
- iOS 14+ 才有横幅/列表选项;旧版系统使用 `Alert` 展示。
- 请在系统设置中允许应用通知的横幅展示。
## 注意事项
- 仅适用于 Expo Bare / 预构建项目;不适用于 Expo Go。
- 本插件不修改 `Entitlements.plist`、不添加权限,也不依赖任何第三方密钥。
- 如果你同时使用 `jpush-expo-plugin`,两者事件通道一致(`JPushEvent`),无需额外桥接代码。
## 验证清单
- 文件注入:`ios/FIJPushForwarder/NotificationForwarder.m` 存在。
- Xcode 工程:该文件位于主 App target 的 `Compile Sources`
- 事件接收:
- 前台收到通知 -> JS 收到 `type = willPresent`
- 点击通知进入 App -> JS 收到 `type = didReceive`
- 冷启动:从通知点击冷启动 App事件在 JS 初始化后仍能收到。
## 兼容性
- iOS12+(针对 iOS 14+ 合并横幅/列表展示选项;更低版本使用 Alert
- RN基于 `RCTEventEmitter`,与 `NativeEventEmitter` 订阅方式兼容。
## 版本与维护
- 插件版本:`1.0.0`
- 修改范围最小化:不、更、不会替换你的现有通知处理逻辑,只做事件补充与转发。
---
如需将该插件在团队文档(如打包指南)中补充说明,可引用本 README 的“原理说明”“JS 使用示例”和“验证清单”三节,帮助快速对齐行为与期望。

View File

@ -0,0 +1,63 @@
const { withDangerousMod, withXcodeProject, createRunOncePlugin } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
function withNotificationForwarderNative(config) {
return withDangerousMod(config, ["ios", async (c) => {
const projectRoot = c.modRequest.projectRoot;
const iosRoot = path.join(projectRoot, 'ios');
const srcDir = path.join(projectRoot, 'NativePlugins', 'notification-forwarder-plugin', 'ios');
const targetDir = path.join(iosRoot, 'FIJPushForwarder');
fs.mkdirSync(targetDir, { recursive: true });
// 复制 iOS 源码(单文件,内含 swizzle + RCTEventEmitter
const files = ['NotificationForwarder.m'];
for (const f of files) {
const from = path.join(srcDir, f);
const to = path.join(targetDir, f);
if (fs.existsSync(from)) {
fs.copyFileSync(from, to);
}
}
return c;
}]);
}
function withNotificationForwarderXcode(config) {
return withXcodeProject(config, (c) => {
const project = c.modResults;
// 确保标准分组存在,避免 node-xcode 在 correctForPath/correctForPluginsPath 中对 null 取 .path 崩溃
const ensureGroup = (name) => {
try {
const g = project.pbxGroupByName(name);
if (!g) {
project.addPbxGroup([], name);
}
} catch (_) {
// ignore
}
};
ensureGroup('Plugins');
ensureGroup('Resources');
ensureGroup('Frameworks');
// 使用 Xcode 工程相对路径,确保添加到主 App target 的 Sources 中
const projectRelativePath = path.join('FIJPushForwarder', 'NotificationForwarder.m');
try {
const targetUuid = project.getFirstTarget().uuid;
project.addSourceFile(projectRelativePath, { target: targetUuid });
} catch (e) {
console.warn('[notification-forwarder-plugin] addSourceFile failed:', e && (e.stack || e.message || e));
}
return c;
});
}
function withNotificationForwarder(config) {
config = withNotificationForwarderNative(config);
config = withNotificationForwarderXcode(config);
return config;
}
module.exports = createRunOncePlugin(withNotificationForwarder, 'notification-forwarder-plugin', '1.0.0');

View File

@ -0,0 +1,216 @@
# react-native-alhspan
Android 单行文本“视觉居中”插件。原理是“ALHSpan 常开 + InkCenterSpan 选择性触发”,以无裁切、高性能、视觉稳定为上线目标。
在新架构 Fabric 下采用“组合组件”方案:隐藏 `<Text>` 参与布局与测量,原生视图负责真实绘制。无需显式指定 `width/height/lineHeight`,即可由内容驱动尺寸;同时提供文本样式桥接(`color`、`fontSize`、`lineHeight`、`fontFamily`、`fontWeight`、`textAlign`),可作为 RN `<Text>` 的单行场景替换。
关键默认行为:
- 未指定 `color` 时默认黑色(避免透明导致不可见)。
- 未指定 `fontSize` 时沿用系统默认字号(通常约 14sp
- 未显式设置宽/高/行高时,由隐藏 `<Text>` 的内容自然驱动容器尺寸;原生绘制层绝对填充该容器。
## 迁移指南
- 不要机械“全局替换 RN `Text`”。仅在单行展示、需要原生叠加绘制(描边/渐变/阴影/抗锯齿等)或对视觉居中有更高要求的场景使用 `ALHText`
- 单行迁移步骤(推荐做法):
- 将目标控件替换为 `ALHText`(保留原样式中的文本相关样式)。
- 移除显式 `lineHeight`,改为仅使用 `fontSize` 让内容自然决定高度。
- 设置 `includeFontPadding={false}`,让 ALHSpan 接管度量并保持无裁切。
- 文案为数字/中文等“无下行部”且需更严格视觉居中时,可设 `inkCenterMode='force'`
- 横向并排、跨字号的场景,父容器使用 `alignItems: 'center'` 保证齐平。
- 段落/多行排版:如果不需要原生叠加绘制,继续使用 RN `Text` 并保留 `lineHeight` 以保证段落节奏,无需改为 `ALHText`
- 迁移校验清单:
- 在关键位置打开 `debug={true}`,观察辅助线(基线/上下边界/几何中线)以确认视觉居中与无裁切。
- 并排的多个控件可统一高度或统一 `fontSize`,必要时在行尾统一 `padding` 保证美观。
示例(从 RN `Text` 迁移到 `ALHText` 的单行控件):
```jsx
// 原代码(注意:单行建议不设置 lineHeight
<Text style={{ color: '#161616', fontSize: 20 }}>10月gj</Text>
// 迁移后(单行场景优先)
<ALHText
style={{ color: '#161616', fontSize: 20 }}
includeFontPadding={false}
inkCenterMode={'auto'}
>
10月gj
</ALHText>
```
## 能力与策略
- ALHSpanAdjustLineHeightSpan移除字体额外内边距并对称分配到 `ascent/descent`,确保测量与绘制一致、无裁切且几何居中。
- InkCenterSpanReplacementSpan在存在明显下行部或墨水上下失衡时进行温和平移夹紧在 `fm.ascent..fm.descent` 范围,带阈值防抖避免不必要挪动。
- 选择性触发:仅对带下行部的连续子串(默认集合 `gjpqy`)施加 `InkCenterSpan`,其它文本只应用 `ALHSpan`,避免无下行部文本被整体抬升。
## 安装
```sh
yarn add react-native-alhspan
# 或
npm install react-native-alhspan
```
Android 端自动链接(内置 `react-native.config.js`)。构建依赖固定为 `react-android@0.79.5`,需与宿主 RN 版本匹配;`peerDependencies` 要求 `react-native >= 0.71.0`
## 使用示例
基础用法(内容驱动尺寸,自动墨水居中):
```jsx
import ALHText from 'react-native-alhspan';
export default function Demo() {
return (
<ALHText
style={{ justifyContent: 'center', alignItems: 'center' }}
// 可通过 text 传入文本,或使用 children二者同时存在时以 text 优先)
text={'202510月gj'}
inkCenterMode={'auto'} // auto|off|force
descenderSet={'gjpqy'}
includeFontPadding={false}
/>
);
}
```
视觉更严格的居中(无下行部数字/中文也居中):
```jsx
<ALHText
style={{ fontSize: 16 }}
inkCenterMode={'force'}
>
2025
</ALHText>
```
横向跨字号并排齐平(父容器统一居中):
```jsx
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<ALHText style={{ fontSize: 14 }} includeFontPadding={false}>文本A</ALHText>
<ALHText style={{ fontSize: 12, marginLeft: 12 }} includeFontPadding={false}>文本B</ALHText>
</View>
```
替换 RN Text单行展示场景优先
```jsx
// 原代码(注意:单行建议不设置 lineHeight
<Text style={{ color: '#161616', fontSize: 20 }}>10月gj</Text>
// 替换为 ALHText
<ALHText
style={{ color: '#161616', fontSize: 20 }}
includeFontPadding={false}
inkCenterMode={'auto'}
>
10月gj
</ALHText>
```
## Props
- `text?: string` 文本内容;若未提供则从 `children` 拼接为字符串。
- `children?: ReactNode` 也可使用子节点传入文本;若同时存在,以 `text` 优先。
- `inkCenterMode?: 'auto' | 'off' | 'force'`(默认 `auto`)。
- `descenderSet?: string` 连续下行部字符集合(默认 `"gjpqy"`)。
- `debug?: boolean` 是否绘制辅助线(默认 `false`)。
- `includeFontPadding?: boolean` 是否包含字体额外内边距(默认 `false`)。
- `style?: StyleProp<TextStyle | ViewStyle>` 支持混合样式(容器布局 + 文本样式)。
- `colorRanges?: Array<{ start: number; end: number; color: string }>` 文本着色范围UTF-16 索引);非法/越界/零宽将被忽略。
- `pressRanges?: Array<{ start: number; end: number; pressId?: string }>` 可点击范围;仅在非空时启用点击链路;片段之间不允许重叠。
- `onSegmentPress?: (e) => void` 片段点击回调,`e.nativeEvent` 包含 `{ pressId, start, end, text }`
- 透传 Text 布局属性:`numberOfLines?`、`allowFontScaling?`(用于隐藏 `<Text>` 布局)。
### 样式桥接(与 RN Text 一致)
- `color` 文本颜色。
- `fontSize` 字号DIP
- `lineHeight` 行高DIP单行场景下固定行盒
- `fontFamily` 字体族。
- `fontWeight` 字重(`normal`/`bold` 或 `100..900`)。
- `textAlign` 文本水平对齐(`auto`/`left`/`right`/`center`)。
实现说明:
- `fontSize/lineHeight` 使用系统 `DisplayMetrics` 做 DIP→PX 转换;目前不区分 `allowFontScaling`
- 指定 `lineHeight` 时采用 `FixedLineHeightSpan`,不再叠加 `ALHSpan`;未指定且 `includeFontPadding=false` 时启用 `ALHSpan` 保持度量/绘制一致。
- 组合组件中,隐藏 `<Text>` 参与布局且不可见,原生视图绝对填充进行绘制。
## 索引范围着色与点击(扩展能力)
- 概述:在不改变度量与居中策略的前提下,可按 `start/end` 索引为文本叠加两类 span颜色`ForegroundColorSpan`)与点击(`ClickableSpan`)。
- 默认行为:不传 `colorRanges/pressRanges` 时完全保持现有行为;仅当 `pressRanges` 非空时启用点击链路并将高亮色设为透明;统一按“颜色 → 点击”的顺序应用 span。
- 索引规则:使用 Java/JS 字符串的 UTF-16 序号;必须满足 `0 <= start < end <= text.length``start == end` 的零宽范围直接忽略。
- 重叠策略:`pressRanges` 之间不允许重叠(后者忽略并警告);`colorRanges` 可重叠,后应用覆盖前应用;颜色与点击可重叠,点击以 `pressRanges` 为准。
- 交互与冲突:仅在存在点击范围时启用 `LinkMovementMethod` 并避免设置 `OnClickListener`,防止与 `ClickableSpan` 冲突;父级 `Pressable/Touchable` 在无点击范围时不受影响。
- Android 端实现要点:将当前文本封装为 `SpannableString`,校验并排序范围;先应用颜色再应用点击;开启点击时使用 `LinkMovementMethod.getInstance()` 与透明高亮;不触碰现有测量与 InkCenter 逻辑。
### 示例:颜色与点击片段
```jsx
import ALHText from 'react-native-alhspan';
export default function SpanRangesDemo() {
return (
<ALHText
style={{ color: '#161616', fontSize: 20 }}
includeFontPadding={false}
inkCenterMode={'auto'}
colorRanges={[{ start: 3, end: 6, color: '#3B82F6' }]}
pressRanges={[{ start: 3, end: 6, pressId: 'topic' }]}
onSegmentPress={(e) => {
const { pressId, start, end, text } = e.nativeEvent;
console.log('segment press', pressId, start, end, text);
}}
>
{'这是第一段包含可点击片段'}
</ALHText>
);
}
```
### 使用建议与边界
- 单行场景优先;多行文本的索引仍以 UTF-16 计算,但段落/排版需求建议继续使用 RN `Text`
- 索引生成建议使用 `Array.from(text)` 等方式在含 emoji/合字时进行校准。
- 若需要固定行高,请评估视觉中心可能变化;不设置 `lineHeight` 的单行场景更稳定。
### 测试建议(摘要)
- 功能:验证单段/多段颜色与点击、越界与零宽忽略、仅范围内触发点击。
- 度量:对比开启/关闭 span 前后 `lineCount/width/height/baseline` 一致。
- 交互:父级 `Pressable` 存在时,无点击范围保持父级手势;有点击范围仅片段触发子事件。
- 性能15k 字符与 ≤50 段范围应用不应产生明显卡顿。
## 典型场景与建议
- 单行控件(按钮、标签、价格、导航文案):使用 `ALHText`,不显式设置 `lineHeight`,仅用 `fontSize``includeFontPadding={false}`。对无下行部的数字/中文可用 `inkCenterMode='force'` 进一步光学校正。
- 横向并排且字号不同:父容器 `flexDirection: 'row', alignItems: 'center'`,各子项使用 `includeFontPadding={false}`;必要时对个别项使用 `inkCenterMode='force'`
- 多行正文/段落:若无需原生叠加绘制,推荐继续使用 RN `Text` 并保留 `lineHeight` 以保证段落节奏。
## 注意事项(上线护栏)
- 保持 `includeFontPadding={false}` 让 ALHSpan 接管度量;如设为 `true`,为了保证度量与绘制一致,将不应用 ALHSpan。
- 固定行高可能带来“墨水视觉偏上”的观感Android 的 `LineHeightSpan` 调整 `FontMetricsInt` 时通常把额外空间更多分配到 `descent/bottom`,导致基线相对上移。单行场景建议不设置 `lineHeight`
- 复杂脚本与连字:`getTextBounds` 对墨水包络的估算可能略保守,但平移量被夹紧到 `fm.ascent..fm.descent` 范围,无裁切风险;关键文案建议联调。
- 可编辑组件:`ReplacementSpan` 不适用于编辑态(如 RN `TextInput`),仅用于展示。
## 性能
- Native复用 `ALHSpan` 单例;仅在必要子串施加 `InkCenterSpan`。字符扫描 O(n),避免正则与多余对象分配。
- RN 桥:只传必要 props更新时增量重建 `SpannableString`,避免整树重建与频繁测量。
- 大列表:建议与 `FlatList` 搭配;文本变更不频繁时效果最佳。
## 常见问题
- 为何设置 `lineHeight` 会看起来更像顶对齐?固定行高改变了行盒与基线的关系,额外空间多在下方,墨水视觉中心相对上移。单行建议移除 `lineHeight`,或对该项做少量 `paddingTop/paddingBottom` 光学校正(不推荐,维护成本高)。
- 如何对齐跨字号文本?父容器 `alignItems: 'center'` 即可齐平;若仍感到墨水偏上,可对该项使用 `inkCenterMode='force'`
- 需要“基线对齐”而非“居中对齐”?当前组件面向居中需求;基线对齐可用容器级对齐组件或原生度量事件扩展实现(不在本插件范围内)。
## License
MIT

View File

@ -0,0 +1,38 @@
import { ReactNode } from 'react';
import { ViewProps, StyleProp, TextStyle, ViewStyle } from 'react-native';
export type InkCenterMode = 'auto' | 'off' | 'force';
export interface ALHTextProps extends ViewProps {
/** 文本内容(可选);也可用 children 传入 */
text?: string;
/** 也可通过 children 传文本内容(将被拼接为字符串) */
children?: ReactNode;
/** 选择性墨水居中策略auto(默认)、off、force */
inkCenterMode?: InkCenterMode;
/** 连续下行部字符集合(默认:"gjpqy"用于auto模式的子串判定 */
descenderSet?: string;
/** 是否绘制辅助线以便调试默认false */
debug?: boolean;
/** 是否包含字体额外内边距默认false */
includeFontPadding?: boolean;
/** 支持 Text 风格与容器布局风格的混合(用于隐藏 Text 测量) */
style?: StyleProp<TextStyle | ViewStyle>;
/** 透传常见 Text 布局属性(如需要) */
numberOfLines?: number;
allowFontScaling?: boolean;
/**
* style RN Text
* - color: 文本颜色
* - fontSize: 字号DIP
* - lineHeight: 行高DIP
* - fontFamily: 字体族
* - fontWeight: 'normal' | 'bold' | '100'..'900'
* - textAlign: 'auto' | 'left' | 'right' | 'center'
*/
}
declare const ALHText: (props: ALHTextProps) => JSX.Element;
export default ALHText;
export { ALHText };
export const ALHTextNative: any;

View File

@ -0,0 +1,120 @@
// React Native ALHSpan 插件导出:组合组件(隐藏 Text 测量 + 原生绘制叠加)
// 在新架构 Fabric 下无需显式指定 width/height/lineHeight即可由内容驱动尺寸。
import React from 'react';
import { requireNativeComponent, StyleSheet, View, Text, processColor, PixelRatio } from 'react-native';
// 全局字体缩放阈值(需与 utils/ui/fontScaling.js 保持一致)
const MAX_FONT_SCALE = 1.3;
// 原生视图名称与 ViewManager.getName 保持一致
const RCTALHText = requireNativeComponent('ALHText');
function extractTextStyle(styleObj) {
const style = StyleSheet.flatten(styleObj) || {};
const { color, fontSize, lineHeight, fontFamily, fontWeight, textAlign } = style;
const textStyleProps = {};
if (color != null) textStyleProps.color = color;
// 手动处理字体缩放限制
if (fontSize != null) {
const fontScale = PixelRatio.getFontScale();
if (fontScale > MAX_FONT_SCALE) {
// 如果系统缩放超过阈值,手动缩小 fontSize 传给原生,
// 使得 原生绘制大小 = fontSize * (MAX/Current) * Current = fontSize * MAX
textStyleProps.fontSize = fontSize * (MAX_FONT_SCALE / fontScale);
} else {
textStyleProps.fontSize = fontSize;
}
}
if (lineHeight != null) textStyleProps.lineHeight = lineHeight;
if (fontFamily != null) textStyleProps.fontFamily = fontFamily;
if (fontWeight != null) textStyleProps.fontWeight = fontWeight;
if (textAlign != null) textStyleProps.textAlign = textAlign;
return { style, textStyleProps };
}
function childrenToText(children) {
if (children == null) return '';
if (typeof children === 'string' || typeof children === 'number') return String(children);
if (Array.isArray(children)) {
return children.map((c) => (typeof c === 'string' || typeof c === 'number') ? String(c) : '').join('');
}
return '';
}
/**
* 组合组件用隐藏的 <Text> 参与布局与测量原生 RCTALHText 进行真正绘制
* 这样在 Fabric 下也能让内容驱动尺寸无需显式 width/height/lineHeight
* usage:
* <ALHText style={...} text="202510月gj" inkCenterMode="auto" />
*
* <ALHText style={...}>202510月gj</ALHText>
*/
export default function ALHText(props) {
const { style: styleProp, children, text: textProp, includeFontPadding, colorRanges, pressRanges, onSegmentPress, ...rest } = props;
const { style, textStyleProps } = extractTextStyle(styleProp);
const text = (textProp != null) ? String(textProp) : childrenToText(children);
// 预处理 colorRanges将颜色转换为原生可识别的整型
const normalizedColorRanges = Array.isArray(colorRanges)
? colorRanges
.map((r) => {
if (!r || typeof r.start !== 'number' || typeof r.end !== 'number') return null;
const colorInt = r.color != null ? processColor(r.color) : null;
return { start: r.start, end: r.end, color: colorInt };
})
.filter(Boolean)
: undefined;
const normalizedPressRanges = Array.isArray(pressRanges)
? pressRanges
.map((r) => {
if (!r || typeof r.start !== 'number' || typeof r.end !== 'number') return null;
return { start: r.start, end: r.end, pressId: r.pressId };
})
.filter(Boolean)
: undefined;
// 隐藏测量文本:参与布局但不可见;容器尺寸由它驱动
const hiddenMeasureTextStyle = [style, styles.hiddenMeasureText];
return (
<View style={style} pointerEvents="box-none">
<Text
style={hiddenMeasureTextStyle}
// 典型 Text 布局属性可透传(如需要)
numberOfLines={rest.numberOfLines}
allowFontScaling={rest.allowFontScaling}
maxFontSizeMultiplier={MAX_FONT_SCALE}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no"
>
{text}
</Text>
<RCTALHText
{...rest}
{...textStyleProps}
text={text}
includeFontPadding={includeFontPadding ?? false}
colorRanges={normalizedColorRanges}
pressRanges={normalizedPressRanges}
onSegmentPress={onSegmentPress}
style={StyleSheet.absoluteFill}
/>
</View>
);
}
export const ALHTextNative = RCTALHText;
const styles = StyleSheet.create({
hiddenMeasureText: {
opacity: 0,
// 让隐藏测量文本不拦截事件,且仍参与布局
position: 'relative',
},
});

View File

@ -0,0 +1,12 @@
{
"name": "react-native-alhspan",
"version": "0.1.0",
"description": "React Native Android插件ALHSpan常开 + InkCenterSpan选择性触发实现无裁切、高性能、视觉稳定的文本行居中。",
"main": "index.js",
"types": "index.d.ts",
"author": "chris",
"license": "MIT",
"peerDependencies": {
"react-native": ">=0.71.0"
}
}

View File

@ -0,0 +1,10 @@
module.exports = {
dependency: {
platforms: {
android: {
sourceDir: 'android',
},
ios: null,
},
},
};

Binary file not shown.

View File

@ -0,0 +1,188 @@
# Android 签名配置插件使用手册signing-config-plugin
> 本插件用于在 Expo React Native 项目的预构建阶段,自动注入 Android 正式签名配置与必要的 Proguard 规则,免去手动编辑 `app/build.gradle` 与拷贝证书的繁琐步骤。文档风格参考仓库 `docs` 目录。
## 目录
- [适用范围](#适用范围)
- [功能概览](#功能概览)
- [文件结构](#文件结构)
- [环境与前提](#环境与前提)
- [接入步骤](#接入步骤)
- [工作原理](#工作原理)
- [Gradle 注入内容](#gradle-注入内容)
- [Proguard 规则](#proguard-规则)
- [构建与运行](#构建与运行)
- [常见问题](#常见问题)
- [注意事项](#注意事项)
- [验证检查清单](#验证检查清单)
## 适用范围
- 管理模式Expo 管理工作流Managed或预构建后裸工作流Prebuild → Bare
- 平台Android
- 构建类型:支持 `debug``release` 均指向同一套签名(可按需调整)
## 功能概览
- 自动复制签名文件:
- 将 `NativePlugins/signing-config-plugin/keystore.properties` 复制到 `android/keystore.properties`
- 将 `NativePlugins/signing-config-plugin/FLink.keystore` 复制到 `android/app/FLink.keystore`
- 自动修改 `app/build.gradle`
- 在 `signingConfigs` 中注入 `release` 签名配置(读取 `android/keystore.properties`
- 在 `buildTypes.release` 中设置 `signingConfig signingConfigs.release`
- 在 `buildTypes.debug` 中设置 `signingConfig signingConfigs.release`(方便真机调试统一签名,可自行改回)
- 注入逻辑带幂等标记,避免重复注入或破坏原配置
- 自动追加 Proguard 规则:
- 向 `android/app/proguard-rules.pro` 追加华为 HMS 相关 keep 规则(若已存在标记则不重复追加)
## 文件结构
- `app.plugin.js`:插件主文件,负责复制证书、修改 Gradle、追加 Proguard 规则
- `keystore.properties`:签名属性文件(示例),格式如下:
```properties
# Android release signing (DO NOT COMMIT THIS FILE)
STORE_FILE=FLink.keystore
STORE_PASSWORD=12345678
KEY_ALIAS=flink
KEY_PASSWORD=12345678
```
- `FLink.keystore`:示例 keystore 文件(建议替换为你自己的证书并管理密钥安全)
## 环境与前提
- 已在 `app.config.js` 注册插件:
```js
plugins: [
// ... 其他插件
[
"./NativePlugins/signing-config-plugin/app.plugin.js",
{}
]
]
```
- 需要可用的 keystore 与正确的 `keystore.properties`
- `STORE_FILE` 相对路径为 `android/app/` 下的文件名(如 `FLink.keystore`
- 密钥口令需与你实际证书匹配
## 接入步骤
1. 将你的 `keystore``keystore.properties` 放入插件目录:
- `NativePlugins/signing-config-plugin/FLink.keystore`
- `NativePlugins/signing-config-plugin/keystore.properties`
2. 执行预构建以应用插件:
```bash
npx expo prebuild --platform android --clean
```
3. 构建或运行到设备:
```bash
pnpm run android -- --device
# 或构建 release
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
```
## 工作原理
- 预构建阶段,插件会:
- 复制签名文件到 `android/``android/app/`
- 读取并修改 `app/build.gradle`
- 确保存在 `signingConfigs.release`,并从 `android/keystore.properties` 读取四个关键项:
- `STORE_FILE`、`STORE_PASSWORD`、`KEY_ALIAS`、`KEY_PASSWORD`
- 将 `buildTypes.release``buildTypes.debug``signingConfig` 指向 `signingConfigs.release`
- 追加 HMS 相关 `Proguard` 规则到 `android/app/proguard-rules.pro`
- 注入过程内置幂等标记(注释),避免重复注入或破坏既有配置
## Gradle 注入内容
- 插件在 `signingConfigs` 中插入如下片段(带注释标记):
```gradle
// [signing-config-plugin] signingConfigs.release start
release {
def keystorePropsFile = rootProject.file("keystore.properties")
if (!keystorePropsFile.exists()) {
throw new GradleException("Missing keystore.properties at project root. Cannot sign release.")
}
def props = new Properties()
keystorePropsFile.withInputStream { stream -> props.load(stream) }
["STORE_FILE", "STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD"].each { key ->
if (!props.containsKey(key) || props.getProperty(key).trim().isEmpty()) {
throw new GradleException("Missing required property '" + key + "' in keystore.properties")
}
}
storeFile file(props.getProperty("STORE_FILE"))
storePassword props.getProperty("STORE_PASSWORD")
keyAlias props.getProperty("KEY_ALIAS")
keyPassword props.getProperty("KEY_PASSWORD")
}
// [signing-config-plugin] signingConfigs.release end
```
- 同时为 `buildTypes.release``buildTypes.debug` 注入:
```gradle
// [signing-config-plugin] buildTypes.release start
signingConfig signingConfigs.release
// [signing-config-plugin] buildTypes.release end
// [signing-config-plugin] buildTypes.debug start
signingConfig signingConfigs.release
// [signing-config-plugin] buildTypes.debug end
```
## Proguard 规则
- 向 `android/app/proguard-rules.pro` 追加(带注释标记,幂等):
```pro
// [signing-config-plugin] huawei proguard start
-ignorewarnings
-keepattributes *Annotation*
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
-keep class com.hianalytics.android.** { *; }
-keep class com.huawei.updatesdk.** { *; }
-keep class com.huawei.hms.** { *; }
// [signing-config-plugin] huawei proguard end
```
## 构建与运行
- 推荐流程:
```bash
# 安装依赖
pnpm install
# 应用插件修改原生工程
npx expo prebuild --platform android --clean
# 运行到设备debug 使用 release 签名,便于真机调试)
pnpm run android -- --device
# 构建生产包APK 或 AAB
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
```
- 更多细节参见:`docs/Expo React Native APK 打包指南.md`
## 常见问题
1. 预构建后提示缺少 `keystore.properties`
- 确认文件已复制到 `android/keystore.properties`(插件会自动复制;若失败,请手动复制)
2. 构建报错 `Missing required property ...`
- 检查 `keystore.properties` 中四个关键项是否齐全且与证书一致
3. `debug` 为何也使用 `release` 签名?
- 便于真机安装与调试(避免多套签名导致安装冲突)。如需还原,可手动修改 `buildTypes.debug``signingConfig`
4. Proguard 规则是否必须?
- 插件追加的是华为 HMS 相关 keep 规则,通常对多家推送/更新 SDK 有益,幂等追加,安全无害
5. 插件会覆盖已有 `signingConfigs.release` 吗?
- 若已有 `release` 子块,插件不会重复注入;若已有其它 `signingConfig` 指向,会替换为 `signingConfigs.release` 并加入标记
## 注意事项
- 密钥安全:
- `keystore``keystore.properties` 不应提交到公共仓库(当前示例为演示用途,正式项目请使用安全方案)
- 建议使用私有仓库/加密存储/CI 注入方式管理签名文件
- 预构建覆盖:
- 请不要直接在原生目录手动修改签名相关配置,下一次 `prebuild` 可能被插件逻辑覆盖或冲突
- 幂等注入:
- 插件通过注释标记实现幂等,避免多次执行导致重复片段
## 验证检查清单
- 文件复制
- [ ] `android/keystore.properties` 存在且内容正确
- [ ] `android/app/FLink.keystore` 存在
- Gradle 配置
- [ ] `app/build.gradle``signingConfigs` 包含 `release`(带插件注释标记)
- [ ] `buildTypes.release``buildTypes.debug` 指向 `signingConfigs.release`(带插件注释标记)
- 构建产物
- [ ] `./gradlew assembleRelease` 成功,生成 `app-release.apk``app-release.aab`
- [ ] 安装到设备成功(`adb install` 或通过 Android Studio
---
如需进一步扩展或接入 CI请结合本仓库 `docs` 的打包指南与你们的发布流程进行调整。

View File

@ -0,0 +1,212 @@
// Expo 配置插件:在 prebuild 阶段注入 Android 正式签名配置
const { withAppBuildGradle, withDangerousMod, createRunOncePlugin } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const PLUGIN_NAME = 'signing-config-plugin';
const PLUGIN_VERSION = '1.0.0';
function ensureSigningConfig(contents) {
// 在 app/build.gradle 的 signingConfigs 中确保存在 release 配置(仅注入一次,具备幂等性)
const startMarker = '// [signing-config-plugin] signingConfigs.release start';
const endMarker = '// [signing-config-plugin] signingConfigs.release end';
if (contents.includes(startMarker)) return contents; // 已注入,直接返回(幂等)
const releaseConfig = `\n ${startMarker}\n release {\n def keystorePropsFile = rootProject.file("keystore.properties")\n if (!keystorePropsFile.exists()) {\n throw new GradleException("Missing keystore.properties at project root. Cannot sign release.")\n }\n def props = new Properties()\n keystorePropsFile.withInputStream { stream -> props.load(stream) }\n ["STORE_FILE", "STORE_PASSWORD", "KEY_ALIAS", "KEY_PASSWORD"].each { key ->\n if (!props.containsKey(key) || props.getProperty(key).trim().isEmpty()) {\n throw new GradleException("Missing required property '" + key + "' in keystore.properties")\n }\n }\n storeFile file(props.getProperty("STORE_FILE"))\n storePassword props.getProperty("STORE_PASSWORD")\n keyAlias props.getProperty("KEY_ALIAS")\n keyPassword props.getProperty("KEY_PASSWORD")\n }\n ${endMarker}\n`;
// 精确定位 signingConfigs 区块并判断其中是否已有 release 子块,避免跨越到 buildTypes.release 的误判
const signingBlockMatch = contents.match(/signingConfigs\s*\{/);
if (signingBlockMatch) {
const openBraceIdx = signingBlockMatch.index + signingBlockMatch[0].length - 1; // '{' 的位置
// 通过括号计数找到对应的收尾 '}' 位置
let level = 0;
let endIdx = -1;
for (let i = openBraceIdx; i < contents.length; i++) {
const ch = contents[i];
if (ch === '{') level++;
else if (ch === '}') {
level--;
if (level === 0) { endIdx = i; break; }
}
}
if (endIdx > -1) {
const block = contents.slice(openBraceIdx, endIdx + 1);
// 若 signingConfigs 区块内已包含 release 子块,则不再注入
if (/\brelease\s*\{/.test(block)) {
return contents;
}
// 在 signingConfigs 区块起始处插入 release 配置
const insertIdx = signingBlockMatch.index + signingBlockMatch[0].length;
return contents.slice(0, insertIdx) + releaseConfig + contents.slice(insertIdx);
}
}
// 否则在 buildTypes 之前新建 signingConfigs 区块并插入
const buildTypesMatch = contents.match(/\n\s*buildTypes\s*\{/);
if (buildTypesMatch) {
const insert = `\n signingConfigs {\n${releaseConfig} }\n\n`;
const idx = buildTypesMatch.index;
return contents.slice(0, idx) + insert + contents.slice(idx);
}
return contents; // 兜底:不做任何修改
}
function ensureBuildTypesReleasePointsToSigning(contents) {
// 确保 buildTypes.release 使用 signingConfigs.release幂等不重复注入
const startMarker = '// [signing-config-plugin] buildTypes.release start';
const endMarker = '// [signing-config-plugin] buildTypes.release end';
if (contents.includes(startMarker)) return contents; // 已注入,直接返回(幂等)
// 若已存在 signingConfig 指向 signingConfigs.release则无需再处理
if (/buildTypes\s*\{[\s\S]*?release\s*\{[\s\S]*?signingConfig\s+signingConfigs\.release[\s\S]*?\}/m.test(contents)) {
return contents;
}
// 若 release 区块存在:
// - 已存在其它 signingConfig则替换为指向 signingConfigs.release
// - 不存在 signingConfig则插入一条
const releaseBlockRegex = /(buildTypes\s*\{[\s\S]*?release\s*\{)([\s\S]*?)(\})/m;
if (releaseBlockRegex.test(contents)) {
return contents.replace(releaseBlockRegex, (m, a, mid, c) => {
const hasAnySigning = /\bsigningConfig\s+signingConfigs\.[^\s]+/.test(mid);
if (hasAnySigning) {
// 替换首个 signingConfig 为 release并用标记包裹便于幂等
const replacedMid = mid.replace(/\bsigningConfig\s+signingConfigs\.[^\s]+/, `${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}`);
return a + replacedMid + c;
}
const inject = `\n ${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}\n`;
// 尽量靠前插入,避免与其他配置混在一起
return a + inject + mid + c;
});
}
// 若缺少 release 区块且存在 buildTypes则新建 release 区块并注入
const buildTypesBlockRegex = /(buildTypes\s*\{)([\s\S]*?)(\})/m;
if (buildTypesBlockRegex.test(contents)) {
return contents.replace(buildTypesBlockRegex, (m, a, mid, c) => {
const injectBlock = `\n release {\n ${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}\n }\n`;
return a + mid + injectBlock + c;
});
}
return contents; // 兜底:不做任何修改
}
function ensureBuildTypesDebugPointsToSigning(contents) {
// 确保 buildTypes.debug 使用 signingConfigs.release幂等不重复注入
const startMarker = '// [signing-config-plugin] buildTypes.debug start';
const endMarker = '// [signing-config-plugin] buildTypes.debug end';
if (contents.includes(startMarker)) return contents; // 已注入,直接返回(幂等)
// 若 debug 已指向 signingConfigs.release则无需处理
if (/buildTypes\s*\{[\s\S]*?debug\s*\{[\s\S]*?signingConfig\s+signingConfigs\.release[\s\S]*?\}/m.test(contents)) {
return contents;
}
// 若 debug 区块存在:
// - 已存在其它 signingConfig则替换为指向 signingConfigs.release
// - 不存在 signingConfig则插入一条
const debugBlockRegex = /(buildTypes\s*\{[\s\S]*?debug\s*\{)([\s\S]*?)(\})/m;
if (debugBlockRegex.test(contents)) {
return contents.replace(debugBlockRegex, (m, a, mid, c) => {
const hasAnySigning = /\bsigningConfig\s+signingConfigs\.[^\s]+/.test(mid);
if (hasAnySigning) {
const replacedMid = mid.replace(/\bsigningConfig\s+signingConfigs\.[^\s]+/, `${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}`);
return a + replacedMid + c;
}
const inject = `\n ${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}\n`;
return a + inject + mid + c;
});
}
// 若缺少 debug 区块且存在 buildTypes则新建 debug 区块并注入
const buildTypesBlockRegex = /(buildTypes\s*\{)([\s\S]*?)(\})/m;
if (buildTypesBlockRegex.test(contents)) {
return contents.replace(buildTypesBlockRegex, (m, a, mid, c) => {
const injectBlock = `\n debug {\n ${startMarker}\n signingConfig signingConfigs.release\n ${endMarker}\n }\n`;
return a + mid + injectBlock + c;
});
}
return contents; // 兜底:不做任何修改
}
function ensureHuaweiProguardRules(proguardPath) {
// 向 proguard-rules.pro 追加 HMS 相关 keep 规则(幂等,多次追加会被拦截)
const start = '// [signing-config-plugin] huawei proguard start';
const end = '// [signing-config-plugin] huawei proguard end';
let content = '';
try { content = fs.existsSync(proguardPath) ? fs.readFileSync(proguardPath, 'utf8') : ''; } catch {}
if (content.includes(start)) return; // 已写入则不重复写
const rules = `\n${start}\n-ignorewarnings\n-keepattributes *Annotation*\n-keepattributes Exceptions\n-keepattributes InnerClasses\n-keepattributes Signature\n-keepattributes SourceFile,LineNumberTable\n-keep class com.hianalytics.android.** { *; }\n-keep class com.huawei.updatesdk.** { *; }\n-keep class com.huawei.hms.** { *; }\n${end}\n`;
fs.writeFileSync(proguardPath, content + rules, 'utf8');
}
const withSigningAssets = (config) =>
withDangerousMod(config, ['android', async (config) => {
const projectRoot = config.modRequest.projectRoot;
const pluginDir = path.join(projectRoot, 'NativePlugins', 'signing-config-plugin');
const srcProps = path.join(pluginDir, 'keystore.properties');
const srcKeystore = path.join(pluginDir, 'FLink.keystore');
const androidRoot = path.join(projectRoot, 'android');
const appDir = path.join(androidRoot, 'app');
const destProps = path.join(androidRoot, 'keystore.properties');
const destKeystore = path.join(appDir, 'FLink.keystore');
try {
if (fs.existsSync(srcProps)) {
fs.copyFileSync(srcProps, destProps);
console.log(`[${PLUGIN_NAME}] 已复制 keystore.properties 到 android/`);
} else {
console.log(`[${PLUGIN_NAME}] 未找到 ${srcProps},跳过复制 keystore.properties`);
}
if (fs.existsSync(srcKeystore)) {
fs.copyFileSync(srcKeystore, destKeystore);
console.log(`[${PLUGIN_NAME}] 已复制 FLink.keystore 到 android/app/`);
} else {
console.log(`[${PLUGIN_NAME}] 未找到 ${srcKeystore},跳过复制 FLink.keystore`);
}
// 始终确保追加 HMS 相关 Proguard 规则(即使未启用 Proguard 也无影响)
ensureHuaweiProguardRules(path.join(appDir, 'proguard-rules.pro'));
} catch (e) {
console.warn(`[${PLUGIN_NAME}] 复制签名文件或写入 Proguard 规则失败:`, e);
}
return config;
}]);
const withSigningConfig = (config) => {
// 第一步:复制证书文件并追加 HMS Proguard 规则
config = withSigningAssets(config);
// 第二步:仅在证书与属性文件同时存在时,才对 app/build.gradle 注入签名及 buildTypes 配置
return withAppBuildGradle(config, (config) => {
const projectRoot = config.modRequest.projectRoot;
const keystorePropsPath = path.join(projectRoot, 'android', 'keystore.properties');
const keystoreFilePath = path.join(projectRoot, 'android', 'app', 'FLink.keystore');
const hasProps = fs.existsSync(keystorePropsPath);
const hasKeystore = fs.existsSync(keystoreFilePath);
if (!hasProps || !hasKeystore) {
console.log(`[${PLUGIN_NAME}] 未找到签名文件keystore.properties 或 FLink.keystore跳过 Gradle 注入。`);
return config; // 保护性兜底:缺少文件则不修改 Gradle
}
let contents = config.modResults.contents;
contents = ensureSigningConfig(contents);
contents = ensureBuildTypesReleasePointsToSigning(contents);
contents = ensureBuildTypesDebugPointsToSigning(contents);
config.modResults.contents = contents;
return config;
});
};
module.exports = createRunOncePlugin(withSigningConfig, PLUGIN_NAME, PLUGIN_VERSION);

View File

@ -0,0 +1,6 @@
# Android release signing (DO NOT COMMIT THIS FILE)
# Path is relative to project root
STORE_FILE=FLink.keystore
STORE_PASSWORD=12345678
KEY_ALIAS=flink
KEY_PASSWORD=12345678

View File

@ -0,0 +1,145 @@
# Android 启动屏品牌化插件使用手册splash-branding-plugin
> 本插件在 Expo React Native 项目预构建阶段对 Android 启动屏进行品牌化配置。它在不直接改动原生目录的前提下,自动复制/生成所需资源,注入自定义样式并绑定到 `MainActivity`,与官方 `expo-splash-screen` 协同工作。文风参考仓库 `docs` 目录。
## 目录
- [适用范围](#适用范围)
- [功能概览](#功能概览)
- [可配置项](#可配置项)
- [接入步骤](#接入步骤)
- [工作原理](#工作原理)
- [与 expo-splash-screen 的协作](#与-expo-splash-screen-的协作)
- [构建与运行](#构建与运行)
- [常见问题](#常见问题)
- [注意事项](#注意事项)
- [验证检查清单](#验证检查清单)
## 适用范围
- 管理模式Expo 管理工作流Managed或预构建后裸工作流Prebuild → Bare
- 平台AndroidiOS 启动屏请继续使用 `expo-splash-screen` 自带配置)
## 功能概览
- 资源处理(在 `windowBackgroundEnabled` 为真时生效):
- 复制全屏启动图到 `android/app/src/main/res/drawable-nodpi/splashscreen_full.png`
- 生成 `android/app/src/main/res/drawable/splashscreen_fullscreen.xml``bitmap` 填充,`gravity=fill`
- 样式注入(`styles.xml`
- 新增自定义主题 `Theme.FLink.SplashScreen`(继承 `Theme.SplashScreen`
- 设置 `windowSplashScreenBackground` 指向全屏图或颜色背景
- 设置 `windowSplashScreenAnimatedIcon` 指向应用图标(`@mipmap/ic_launcher[_round]`)或回退到 `@drawable/splashscreen_fullscreen`
- 设置 `postSplashScreenTheme``@style/AppTheme`(进入应用后主题)
- 若开启 `windowBackgroundEnabled`,将 `AppTheme``android:windowBackground` 指向全屏图(如存在)
- Manifest 注入:
- 将 `MainActivity``android:theme` 指向 `@style/Theme.FLink.SplashScreen`
## 可配置项
`app.config.js` 中为该插件传参(均可选):
- `fullScreenImage`(默认 `./assets/images/splash-icon.png`
- 全屏启动图的项目相对路径(最终复制到 `drawable-nodpi/splashscreen_full.png`
- `windowBackgroundEnabled`(默认 `false`
- 开启后使用全屏位图作为 `windowSplashScreenBackground`(更贴合品牌视觉);未开启则沿用颜色背景 `@color/splashscreen_background`
- `useRoundIcon`(默认 `true`
- 决定 `windowSplashScreenAnimatedIcon` 指向 `@mipmap/ic_launcher_round``@mipmap/ic_launcher`
- `useAppIconAsSplashLogo`(默认 `true`
- 为启动屏动画 logo 使用应用图标;如果为 `false`,且 `fallbackToDrawableLogo=true` 且已生成 drawable全屏位图将作为替代
- `fallbackToDrawableLogo`(默认 `false`
- 当不使用应用图标时,是否回退到 `@drawable/splashscreen_fullscreen` 作为动画 logo
示例:
```js
// app.config.js
plugins: [
// ... 其他插件
[
"expo-splash-screen",
{ image: "./assets/images/splash-icon.png", resizeMode: "cover", enableFullScreenImage_legacy: true }
],
[
"./NativePlugins/splash-branding-plugin/app.plugin.js",
{
windowBackgroundEnabled: true,
fullScreenImage: "./assets/images/splash-icon.png",
useRoundIcon: true,
useAppIconAsSplashLogo: true,
fallbackToDrawableLogo: false,
}
]
]
```
## 接入步骤
1. 在 `app.config.js` 注册插件(示例见上);建议置于 `expo-splash-screen` 之后。
2. 将你的全屏启动图放在项目路径,并通过 `fullScreenImage` 指定。
3. 预构建以应用资源与样式注入:
```bash
npx expo prebuild --platform android --clean
```
4. 运行或构建应用:
```bash
pnpm run android -- --device
# 或构建 release
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
```
## 工作原理
- 资源阶段:
- 在 `android/app/src/main/res/drawable-nodpi` 写入 `splashscreen_full.png`
- 在 `android/app/src/main/res/drawable` 写入 `splashscreen_fullscreen.xml``bitmap` 指向前者)
- 样式阶段:
- 在 `styles.xml` 注入 `Theme.FLink.SplashScreen`,并按配置选择背景与动画 logo 源
- 可选更新 `AppTheme``android:windowBackground` 为全屏图
- Manifest 阶段:
- 将 `MainActivity``android:theme` 更新为 `@style/Theme.FLink.SplashScreen`
- 幂等:
- 每次运行前移除旧的 `Theme.FLink.SplashScreen` 定义,重新生成,避免重复与污染
## 与 expo-splash-screen 的协作
- 推荐在 `plugins` 顺序上,先声明 `expo-splash-screen`,再加入本插件。
- 本插件以 `Theme.SplashScreen` 作为父主题,保证与官方行为一致,同时提供品牌化定制能力。
- 若后续变更官方配置(如颜色、图片),重新预构建即可由本插件覆盖到最新组合。
## 构建与运行
- 推荐流程:
```bash
pnpm install
npx expo prebuild --platform android --clean
pnpm run android -- --device
# 生产构建
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
```
- 更多打包细节参见:`docs/Expo React Native APK 打包指南.md`
## 常见问题
1. 运行后仍显示默认启动屏?
- 检查插件顺序是否在 `expo-splash-screen` 之后
- 确认已执行 `npx expo prebuild --platform android --clean`
- 检查 `styles.xml` 是否包含 `Theme.FLink.SplashScreen`Manifest 的 `MainActivity` 是否引用该主题
2. 全屏图片不生效/被拉伸异常?
- 使用足够分辨率的位图,并放置于 `drawable-nodpi` 避免缩放
- `gravity=fill` 会铺满屏幕,如需居中展示可改为 `center`(需调整插件或手动资源)
3. 动画 logo 与背景冲突?
- 若使用应用图标作为动画 logo建议选择与背景有对比的图像避免视觉不清晰
4. 与其它主题/样式冲突?
- 本插件每次注入前会清理旧的 `Theme.FLink.SplashScreen` 定义;如仍冲突,请检查是否有第三方插件写入同名主题或覆盖 `MainActivity` 主题
## 注意事项
- 预构建覆盖:请避免直接手动改动原生 `styles.xml``AndroidManifest.xml` 中与启动屏相关的配置,下一次 `prebuild` 可能被覆盖
- 资源路径:`fullScreenImage` 为项目路径(相对 root插件将解析后复制到原生资源目录
- 兼容性:本插件基于 Android 12+ 启动屏机制(`Theme.SplashScreen`),与旧版本兼容性已在多数场景验证;如发现机型差异,请反馈
## 验证检查清单
- 文件与资源
- [ ] `android/app/src/main/res/drawable-nodpi/splashscreen_full.png` 存在
- [ ] `android/app/src/main/res/drawable/splashscreen_fullscreen.xml` 存在
- 样式与主题
- [ ] `styles.xml` 包含 `Theme.FLink.SplashScreen`,且 `windowSplashScreenBackground``windowSplashScreenAnimatedIcon` 指向正确资源
- [ ] `AppTheme`(如启用)包含 `android:windowBackground=@drawable/splashscreen_fullscreen`
- Manifest
- [ ] `AndroidManifest.xml``MainActivity``android:theme="@style/Theme.FLink.SplashScreen"`
- 运行体验
- [ ] 应用冷启动时显示品牌化启动屏;进入应用后主题正常切换到 `AppTheme`
---
如需定制更多展示效果(如居中、不同分辨率资源、多语言版本),可在本插件基础上扩展资源生成逻辑或增加配置项。

View File

@ -0,0 +1,116 @@
const { withAndroidStyles, withDangerousMod, createRunOncePlugin, withAndroidManifest, AndroidConfig } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* Splash Branding Config Plugin
* - 在预构建阶段
* 1) 复制全屏启动图到 res/drawable-nodpi/splashscreen_full.png
* 2) 生成 drawable/splashscreen_fullscreen.xml (bitmap fill)
* 3) 注入自定义主题 Theme.FLink.SplashScreen 并设置 windowSplashScreenAnimatedIcon 指向 @mipmap/ic_launcher @mipmap/ic_launcher_round
* 4) MainActivity android:theme 指向 Theme.FLink.SplashScreen确保使用我们的小图标配置
*/
function withSplashBrandingPlugin(config, props = {}) {
const {
fullScreenImage = './assets/images/splash-icon.png',
windowBackgroundEnabled = false,
useRoundIcon = true,
useAppIconAsSplashLogo = true,
fallbackToDrawableLogo = false,
} = props;
// 资源准备:复制全屏图片 + 生成 bitmap drawable
config = withDangerousMod(config, [
'android',
(c) => {
const projectRoot = c.modRequest.projectRoot;
const resDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'res');
try {
if (windowBackgroundEnabled) {
const nodpiDir = path.join(resDir, 'drawable-nodpi');
fs.mkdirSync(nodpiDir, { recursive: true });
const destPng = path.join(nodpiDir, 'splashscreen_full.png');
const srcImage = path.resolve(projectRoot, fullScreenImage);
if (fs.existsSync(srcImage)) {
fs.copyFileSync(srcImage, destPng);
} else {
console.warn(`[splash-branding-plugin] fullScreenImage not found: ${srcImage}`);
}
const drawableDir = path.join(resDir, 'drawable');
fs.mkdirSync(drawableDir, { recursive: true });
const xmlPath = path.join(drawableDir, 'splashscreen_fullscreen.xml');
const xmlContent = `<?xml version="1.0" encoding="utf-8"?>\n<bitmap xmlns:android="http://schemas.android.com/apk/res/android"\n android:src="@drawable/splashscreen_full"\n android:gravity="fill" />\n`;
fs.writeFileSync(xmlPath, xmlContent);
}
} catch (e) {
console.warn('[splash-branding-plugin] write drawable resources failed:', e);
}
return c;
},
]);
// Styles 注入:新增并强制使用自定义 Theme.FLink.SplashScreen 样式,避免被官方 Theme.App.SplashScreen 覆写
config = withAndroidStyles(config, (c) => {
try {
const styles = c.modResults;
if (!styles || !styles.resources) return c;
const resources = styles.resources;
const styleArr = resources.style || (resources.style = []);
// 删除已有的自定义主题,避免重复
resources.style = styleArr.filter((s) => s.$?.name !== 'Theme.FLink.SplashScreen');
const defaultAppIcon = useRoundIcon ? '@mipmap/ic_launcher_round' : '@mipmap/ic_launcher';
const hasDrawableSplash = windowBackgroundEnabled;
const preferredIcon = useAppIconAsSplashLogo
? defaultAppIcon
: (fallbackToDrawableLogo && hasDrawableSplash ? '@drawable/splashscreen_fullscreen' : defaultAppIcon);
const unlinkBg = windowBackgroundEnabled ? '@drawable/splashscreen_fullscreen' : '@color/splashscreen_background';
const themeSplash = {
$: { name: 'Theme.FLink.SplashScreen', parent: 'Theme.SplashScreen' },
item: [
{ $: { name: 'windowSplashScreenBackground' }, _: unlinkBg },
{ $: { name: 'windowSplashScreenAnimatedIcon' }, _: preferredIcon },
{ $: { name: 'postSplashScreenTheme' }, _: '@style/AppTheme' },
],
};
resources.style.push(themeSplash);
// 可选:确保 AppTheme 的 windowBackground 指向我们的全屏图
if (windowBackgroundEnabled) {
const appTheme = (resources.style || []).find((s) => s.$?.name === 'AppTheme');
if (appTheme) {
const items = appTheme.item || (appTheme.item = []);
const existing = items.find((i) => i.$?.name === 'android:windowBackground');
if (existing) existing._ = '@drawable/splashscreen_fullscreen';
else items.push({ $: { name: 'android:windowBackground' }, _: '@drawable/splashscreen_fullscreen' });
}
}
} catch (e) {
console.warn('[splash-branding-plugin] withAndroidStyles (custom theme) failed:', e);
}
return c;
});
// Manifest 注入:将 MainActivity 的 theme 指向我们自定义的 Theme.FLink.SplashScreen
config = withAndroidManifest(config, (c) => {
try {
const mainActivity = AndroidConfig.Manifest.getMainActivityOrThrow(c.modResults);
if (!mainActivity.$) mainActivity.$ = {};
mainActivity.$['android:theme'] = '@style/Theme.FLink.SplashScreen';
} catch (e) {
console.warn('[splash-branding-plugin] withAndroidManifest failed:', e);
}
return c;
});
return config;
}
module.exports = createRunOncePlugin(withSplashBrandingPlugin, 'splash-branding-plugin', '1.4.0');

View File

@ -0,0 +1,187 @@
# WeChat Lib 配置插件使用手册wechat-lig-config-plugin
> 本插件用于 Expo React Native 项目中自动化接入微信 SDKreact-native-wechat-lib在预构建阶段一次性完成 iOS 与 Android 的原生配置,并在 JS 侧提供原生模块名称别名适配。风格参考本仓库 `docs` 目录下的文档。
## 目录
- [适用范围](#适用范围)
- [功能概览](#功能概览)
- [环境与前提](#环境与前提)
- [安装与接入](#安装与接入)
- [配置项](#配置项)
- [运行与构建](#运行与构建)
- [JS 使用示例](#js-使用示例)
- [原生改动预期](#原生改动预期)
- [目录说明](#目录说明)
- [常见问题](#常见问题)
- [注意事项](#注意事项)
- [验证检查清单](#验证检查清单)
## 适用范围
- 管理模式Expo 管理工作流Managed或预构建后裸工作流Prebuild → Bare
- SDK`react-native-wechat-lib`
- 不支持Expo Go因其不包含自定义原生模块请使用自定义开发客户端或原生构建包
## 功能概览
- iOS
- 自动向 `Info.plist` 写入 `LSApplicationQueriesSchemes``weixin / wechat / weixinULAPI`
- 自动向 `CFBundleURLTypes` 添加以微信 `AppID` 为 scheme 的 URL`wx...`
- 若提供 `Universal Link`,向 `Entitlements.plist` 写入 `Associated Domains``applinks:<host>`
- 自动在 `AppDelegate` 注入 `WXApi``openURL``handleOpenUniversalLink` 调用(兼容 Swift/ObjC避免重复 override
- 自动在 Swift 工程的 Bridging Header 中加入 `#import "WXApi.h"`
- 自动向 `Podfile` 注入 `pod 'WechatOpenSDK-XCFramework'`
- Android
- 自动向 `AndroidManifest.xml` 添加 `queries``com.tencent.mm`
- 自动在 `MainApplication` 注入 `WeChatLibPackage``import``packages.add(...)`(兼容 Java/Kotlin
- 自动按包名生成并放置 `WXEntryActivity.kt`(路径:`android/app/src/main/java/<your.package>/wxapi/WXEntryActivity.kt`
- JS 侧
- 提供原生模块名称别名适配(`WeChat / WechatLib / Wechat`),保证在不同原生注册名下 JS 能一致访问
- 提供“握手 URL 过滤器”,忽略微信 SDK 握手深链,避免干扰业务路由(已集成到 `app/_layout.jsx`
## 环境与前提
- 你需要具备有效的微信开放平台应用:
- `AppID`(形如 `wxXXXXXXXXXXXXXXX`
- 已在微信开放平台配置 `Universal Link`(可选但强烈推荐,分享/登录等更稳定)
- 本项目通过环境变量传递:
- `EXPO_PUBLIC_WECHAT_APPID`
- `EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK`(如 `https://your.domain/path/to/universal/link/`
- 示例(见 `envs/.env.*`
```bash
EXPO_PUBLIC_WECHAT_APPID='wxb99daf7fc34e8e9b'
EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK='https://oa-b.szjixun.cn/api/static/fiee-link/'
```
## 安装与接入
1. 在 Expo 配置中注册插件(已在仓库中完成):
- 文件:`app.config.js`
- 片段:
```js
plugins: [
// ... 其他插件
[
"./NativePlugins/wechat-lig-config-plugin/app.plugin.js",
{}
]
]
```
2. 在应用最早期引入 JS 别名适配器(已在仓库中完成):
- 文件:`app/_layout.jsx`
- 片段:
```js
import '../NativePlugins/wechat-lig-config-plugin/wechat-native-alias';
```
3. 在应用启动时注册微信:
- 文件:`app/_layout.jsx`
- 已集成(`registerApp(WECHAT_APPID, WECHAT_UNIVERSAL_LINK)`
## 配置项
- 环境变量(在 `envs/.env.dev|test|prod.fiee|prod.moce` 中维护):
- `EXPO_PUBLIC_WECHAT_APPID`:微信开放平台 AppID作为 iOS URL Scheme 使用
- `EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK`:用于 iOS Universal Link可选
- 注意:
- iOS 的 URL Scheme 使用 `AppID` 本体(`wx...`),无需额外前缀
- Universal Link 必须是可访问的 `https` 地址,且在开放平台已正确配置
## 运行与构建
- 开发/预构建:
```bash
# 安装依赖
pnpm install
# 预构建(应用 config plugin 到原生工程)
npx expo prebuild --platform ios --clean
npx expo prebuild --platform android --clean
# 运行到设备(示例)
pnpm run ios -- --device
pnpm run android -- --device
```
- 生产构建(详见 `docs/Expo React Native IPA 打包指南.md``docs/Expo React Native APK 打包指南.md`
```bash
# iOS示例
npx expo run:ios --configuration Release --device
# Android示例
pnpm run android:prod:moce -- --variant release
pnpm run android:prod:fiee -- --variant release
```
## JS 使用示例
- 在业务中使用 `react-native-wechat-lib`
```ts
import { registerApp, sendAuthRequest, isWXAppInstalled } from 'react-native-wechat-lib';
async function initWeChat() {
const appId = process.env.EXPO_PUBLIC_WECHAT_APPID;
const ul = process.env.EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK;
if (ul) {
registerApp(appId, ul);
} else {
registerApp(appId);
}
}
async function loginViaWeChat() {
const installed = await isWXAppInstalled();
if (!installed) throw new Error('未检测到微信客户端');
const res = await sendAuthRequest('snsapi_userinfo');
// TODO: 将 res.code 发送给后端,换取 access token 与用户信息
}
```
- 别名适配器保障:即便原生模块注册名不同,`NativeModules.WeChat / WechatLib / Wechat` 均可访问到正确模块
## 原生改动预期
- iOS
- `Info.plist`:包含 `LSApplicationQueriesSchemes``weixin/wechat/weixinULAPI`
- `CFBundleURLTypes`:包含以 `AppID` 为 scheme 的 URL 类型
- `Entitlements.plist`:若配置了 `Universal Link`,包含 `com.apple.developer.associated-domains``applinks:<host>`
- `AppDelegate`:已注入 `WXApi.handleOpen(...)``WXApi.handleOpenUniversalLink(...)`
- `Podfile`:注入 `pod 'WechatOpenSDK-XCFramework'`
- Swift 工程的 Bridging Header包含 `#import "WXApi.h"`
- Android
- `AndroidManifest.xml`:新增 `queries` 指向 `com.tencent.mm`
- `MainApplication`:已导入并注册 `WeChatLibPackage`
- 生成文件:`android/app/src/main/java/<your.package>/wxapi/WXEntryActivity.kt`
## 目录说明
- `app.plugin.js`Expo 配置插件主体,负责在预构建阶段修改 iOS/Android 原生工程
- `wechat-native-alias.js`JS 侧原生模块名称别名适配器,需在应用入口处尽早引入
## 常见问题
1. 运行在 Expo Go
- 不支持。自定义原生模块需使用 **自定义开发客户端****原生构建包**
2. iOS 回调未触发/无法跳转?
- 检查 `CFBundleURLTypes` 是否存在以 `AppID` 为 scheme 的条目
- 检查 `Universal Link` 是否已正确在开放平台与 `apple-app-site-association` 配置
3. Android 无法唤起微信?
- 确认设备安装了微信客户端
- 检查 `AndroidManifest.xml` 中是否存在 `queries → com.tencent.mm`
4. Pod 依赖注入失败?
- 手动在 `Podfile` 的主 target 中加入:`pod 'WechatOpenSDK-XCFramework'`
- 然后执行:`cd ios && pod install --repo-update`
5. `MainApplication` 注入失败?
- 确认工程语言Java/Kotlin`PackageList` 用法是否匹配
- 手动在 `packages` 集合中添加:`new WeChatLibPackage()` 或 `WeChatLibPackage()`
6. 别名未生效?
- 确认 `app/_layout.jsx` 顶部存在:`import '../NativePlugins/wechat-lig-config-plugin/wechat-native-alias'`
## 注意事项
- `EXPO_PUBLIC_WECHAT_APPID` 必须为微信开放平台的合法 AppID形如 `wx...`),同时用作 iOS 的 URL Scheme
- `EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK` 推荐使用 `https`,并在开放平台与服务器侧正确配置 `apple-app-site-association`
- 任何对原生目录的**直接修改**都可能在下一次 `prebuild` 时被覆盖,建议通过插件或脚本统一管理
- 本插件对 `AppDelegate` 的注入逻辑会尽量避免重复 override若你的工程已有其他库注入相同方法插件会在方法首行插入 `WXApi` 调用以复用现有方法
## 验证检查清单
- iOS
- [ ] `Info.plist` 中包含 `weixin/wechat/weixinULAPI`
- [ ] `CFBundleURLTypes` 有以 `AppID` 为 scheme 的 URL 类型
- [ ] `Entitlements.plist` 包含 `applinks:<your-host>`(如配置了 Universal Link
- [ ] `Podfile` 注入了 `WechatOpenSDK-XCFramework``pod install` 成功
- [ ] App 启动后调用 `registerApp(APPID, UNIVERSAL_LINK)` 无异常
- Android
- [ ] `AndroidManifest.xml``queries` 包含 `com.tencent.mm`
- [ ] `MainApplication` 中已添加 `WeChatLibPackage`
- [ ] 生成了 `WXEntryActivity.kt` 并位于 `.../wxapi/` 目录
- [ ] 能从应用唤起微信并接收回调
---
如需进一步排查或扩展,请参考本仓库 `docs` 目录中的打包与构建指南,并结合官方 `react-native-wechat-lib` 与微信开放平台文档进行验证。

View File

@ -0,0 +1,344 @@
const {
withInfoPlist,
withEntitlementsPlist,
withAppDelegate,
withAndroidManifest,
withMainApplication,
withDangerousMod,
AndroidConfig,
} = require('@expo/config-plugins');
const path = require('path');
const fs = require('fs');
module.exports = function withWeChatPlugin(config, props = {}) {
// 加载 .env
try { require('dotenv').config(); } catch (e) {}
const appId = process.env.EXPO_PUBLIC_WECHAT_APPID || '';
const universalLink = process.env.EXPO_PUBLIC_WECHAT_UNIVERSAL_LINK || '';
// iOS: Info.plist 注入LSApplicationQueriesSchemes、CFBundleURLTypes
config = withInfoPlist(config, (c) => {
const info = c.modResults;
// 1) LSApplicationQueriesSchemes
const queries = new Set(info.LSApplicationQueriesSchemes || []);
['weixin', 'wechat', 'weixinULAPI'].forEach((s) => queries.add(s));
info.LSApplicationQueriesSchemes = Array.from(queries);
// 2) CFBundleURLTypes添加以微信 AppID 为 scheme 的 URL
const urlTypes = info.CFBundleURLTypes || [];
const scheme = appId; // 官方要求使用 wxAPPID 作为回调 scheme
const exists = urlTypes.some((t) => Array.isArray(t.CFBundleURLSchemes) && t.CFBundleURLSchemes.includes(scheme));
if (!exists) {
urlTypes.push({
CFBundleURLName: 'weixin',
CFBundleURLSchemes: [scheme]
});
}
info.CFBundleURLTypes = urlTypes;
return c;
});
// iOS: Associated Domains若提供了 Universal Link则写入 entitlements
config = withEntitlementsPlist(config, (c) => {
const ent = c.modResults;
if (universalLink && /^https?:\/\//.test(universalLink)) {
try {
const host = new URL(universalLink).host;
if (host) {
const key = 'com.apple.developer.associated-domains';
const list = new Set(ent[key] || []);
list.add(`applinks:${host}`);
ent[key] = Array.from(list);
}
} catch (e) {}
}
return c;
});
// iOS: AppDelegate 注入openURL / universal link 回调)
config = withAppDelegate(config, (c) => {
let contents = c.modResults.contents;
const isSwift = c.modResults.language === 'swift';
if (isSwift) {
// 不注入 `import WXApi`,通过 Bridging Header 暴露 WXApi 符号
const hasOpenCall = /WXApi\.handleOpen\(url,\s*delegate:\s*nil\)/.test(contents);
const hasUniversalCall = /WXApi\.handleOpenUniversalLink\(userActivity,\s*delegate:\s*nil\)/.test(contents);
// 更通用的签名匹配_ app: UIApplication 或 _ application: UIApplication 等均可
const openMethodSigRe = /func\s+application\(\s*_?\s*\w+\s*:\s*UIApplication\s*,\s*open\s+url:\s*URL\s*,\s*options:[^\)]*\)\s*->\s*Bool\s*\{\s*/;
const continueMethodSigRe = /func\s+application\(\s*_?\s*\w+\s*:\s*UIApplication\s*,\s*continue\s+userActivity:\s*NSUserActivity\s*,\s*restorationHandler:\s*@escaping\s*\(\[[^\)]*\]\?\)\s*->\s*Void\)\s*->\s*Bool\s*\{\s*/;
// 预定义我们曾插入过的完整方法文本,便于清理重复
const injectedOpenURLImpl = `\n public override func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {\n if WXApi.handleOpen(url, delegate: nil) { return true }\n return super.application(application, open: url, options: options)\n }\n`;
const injectedContinueImpl = `\n public override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {\n if WXApi.handleOpenUniversalLink(userActivity, delegate: nil) { return true }\n return super.application(application, continue: userActivity, restorationHandler: restorationHandler)\n }\n`;
// 若当前文件中既存在方法签名(来自其他库)又存在我们之前插入的完整方法,则删除我们插入的方法,避免重复 override
if (openMethodSigRe.test(contents) && contents.includes(injectedOpenURLImpl)) {
contents = contents.replace(injectedOpenURLImpl, '');
}
if (continueMethodSigRe.test(contents) && contents.includes(injectedContinueImpl)) {
contents = contents.replace(injectedContinueImpl, '');
}
// 1) openURL 注入:若已有调用则跳过;若已有方法则插入首行;否则按原策略追加方法
if (!hasOpenCall) {
if (openMethodSigRe.test(contents)) {
if (!/WXApi\.handleOpen\(url,\s*delegate:\s*nil\)/.test(contents)) {
contents = contents.replace(openMethodSigRe, (m) => `${m} if WXApi.handleOpen(url, delegate: nil) { return true }\n`);
}
} else if (!/application\(\s*_?\s*\w+\s*:\s*UIApplication\s*,\s*open\s+url:\s*URL\s*,\s*options:/.test(contents)) {
const openURLImpl = injectedOpenURLImpl;
const insertRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (insertRe.test(contents)) {
contents = contents.replace(insertRe, `\n${openURLImpl}}\n\nclass ReactNativeDelegate:`);
}
}
}
// 2) continueUserActivity 注入:若已有调用则跳过;若已有方法则插入首行;否则按原策略追加方法
if (!hasUniversalCall) {
if (continueMethodSigRe.test(contents)) {
if (!/WXApi\.handleOpenUniversalLink\(userActivity,\s*delegate:\s*nil\)/.test(contents)) {
contents = contents.replace(continueMethodSigRe, (m) => `${m} if WXApi.handleOpenUniversalLink(userActivity, delegate: nil) { return true }\n`);
}
} else if (!/application\(\s*_?\s*\w+\s*:\s*UIApplication\s*,\s*continue\s+userActivity:/.test(contents)) {
const continueImpl = injectedContinueImpl;
const insertRe = /\n\}\n\s*\nclass\s+ReactNativeDelegate:/;
if (insertRe.test(contents)) {
contents = contents.replace(insertRe, `\n${continueImpl}}\n\nclass ReactNativeDelegate:`);
}
}
}
} else {
// Objective-C
if (!contents.includes('#import "WXApi.h"')) {
contents = `#import "WXApi.h"\n${contents}`;
}
// openURL
if (!/application:\s*\(UIApplication \*\)application\s+openURL:\s*\(NSURL \*\)url\s+options:/.test(contents)) {
contents += `\n- (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary<UIApplicationOpenURLOptionsKey,id> *)options {\n if ([WXApi handleOpenURL:url delegate:nil]) { return YES; }\n return [super application:application openURL:url options:options];\n}\n`;
}
// continueUserActivity
if (!/continueUserActivity:\s*\(NSUserActivity \*\)userActivity/.test(contents)) {
contents += `\n- (BOOL)application:(UIApplication *)application continueUserActivity:(NSUserActivity *)userActivity restorationHandler:(void (^)(NSArray<id<UIUserActivityRestoring>> * _Nullable))restorationHandler {\n if ([WXApi handleOpenUniversalLink:userActivity delegate:nil]) { return YES; }\n return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler];\n}\n`;
}
}
c.modResults.contents = contents;
return c;
});
// Android: Manifest 与 WXEntryActivity 自动化
config = withAndroidManifest(config, (c) => {
const manifest = c.modResults.manifest;
const app = AndroidConfig.Manifest.getMainApplicationOrThrow(c.modResults);
// 1) 添加 queries -> com.tencent.mm
if (!manifest.queries) manifest.queries = [];
const hasWeChatQuery = manifest.queries.some((q) => Array.isArray(q.package) && q.package.some((p) => p.$ && p.$['android:name'] === 'com.tencent.mm'));
if (!hasWeChatQuery) {
manifest.queries.push({ package: [{ $: { 'android:name': 'com.tencent.mm' } }] });
}
// 2) 注册 WXEntryActivity
const activityName = `${c.android?.package || c.ios?.bundleIdentifier || 'com.example.app'}.wxapi.WXEntryActivity`;
const appActivities = app['activity'] || [];
const existed = appActivities.find((a) => a.$ && a.$['android:name'] === activityName);
if (!existed) {
app['activity'] = [
...appActivities,
{
$: {
'android:name': activityName,
'android:exported': 'true',
'android:launchMode': 'singleTask',
'android:label': '@string/app_name',
'android:taskAffinity': `${c.android?.package || ''}`.replace(/\.$/, '')
}
}
];
}
return c;
});
// Android: 在 MainApplication 中注册 WeChatLibPackage通过 Expo 插件注入,禁止直接改原生目录)
config = withMainApplication(config, (c) => {
try {
let src = c.modResults.contents || '';
const isKotlin = c.modResults.language === 'kt';
// 1) 注入 import
if (!src.includes('import com.wechatlib.WeChatLibPackage')) {
const lines = src.split('\n');
let inserted = false;
// 优先插入到最后一个 import 之后
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].trim().startsWith('import ')) {
lines.splice(i + 1, 0, 'import com.wechatlib.WeChatLibPackage');
inserted = true;
break;
}
}
if (!inserted) {
// 若没有 import则尝试在 package 行后插入
const pkgIdx = lines.findIndex((l) => l.trim().startsWith('package '));
if (pkgIdx >= 0) {
lines.splice(pkgIdx + 1, 0, 'import com.wechatlib.WeChatLibPackage');
inserted = true;
}
}
if (!inserted) {
src = `import com.wechatlib.WeChatLibPackage\n${src}`;
} else {
src = lines.join('\n');
}
}
// 2) 注入 package 注册
if (isKotlin) {
if (!src.includes('WeChatLibPackage()')) {
if (src.includes('PackageList(this).packages.apply {')) {
src = src.replace('PackageList(this).packages.apply {', 'PackageList(this).packages.apply {\n add(WeChatLibPackage())');
} else if (src.includes('PackageList(this).packages')) {
src = src.replace('PackageList(this).packages', 'PackageList(this).packages.apply {\n add(WeChatLibPackage())\n }');
}
}
} else {
// Java
if (!src.includes('new WeChatLibPackage()')) {
const assignRe = /List<ReactPackage>\s+packages\s*=\s*new PackageList\(this\)\.getPackages\(\);/;
if (assignRe.test(src)) {
src = src.replace(assignRe, (m) => `${m}\n packages.add(new WeChatLibPackage());`);
} else if (src.includes('return new PackageList(this).getPackages();')) {
src = src.replace('return new PackageList(this).getPackages();', 'List<ReactPackage> packages = new PackageList(this).getPackages();\n packages.add(new WeChatLibPackage());\n return packages;');
}
}
}
c.modResults.contents = src;
} catch (e) {
// 保底不影响构建
if (process.env.NODE_ENV !== 'production') {
console.warn('[WeChatPlugin] withMainApplication injection skipped:', e?.message || e);
}
}
return c;
});
// 生成 WXEntryActivity.kt 到 android 原生目录(根据包名)
config = withDangerousMod(config, [
'android',
async (c) => {
const pkg = c.android?.package || c.ios?.bundleIdentifier || 'com.example.app';
const segments = pkg.split('.');
const wxapiDir = path.join(c.modRequest.platformProjectRoot, 'app', 'src', 'main', 'java', ...segments, 'wxapi');
const javaPath = path.join(wxapiDir, 'WXEntryActivity.kt');
if (!fs.existsSync(wxapiDir)) fs.mkdirSync(wxapiDir, { recursive: true });
const javaPkg = `${pkg}.wxapi`;
const javaContent = `package ${javaPkg};
import android.app.Activity;
import android.os.Bundle;
import com.wechatlib.WeChatLibModule;
class WXEntryActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WeChatLibModule.handleIntent(intent)
finish()
}
}
`;
// 仅在文件不存在时创建,避免重复覆盖用户自定义
if (!fs.existsSync(javaPath)) {
fs.writeFileSync(javaPath, javaContent, 'utf8');
console.log('[WeChat] 已创建 WXEntryActivity.kt:', javaPath);
}
return c;
}
]);
// iOS: 在 Podfile 注入 WechatOpenSDK 依赖(优先使用 XCFramework避免缺符号链接错误
config = withDangerousMod(config, [
'ios',
(c) => {
try {
const podfilePath = path.join(c.modRequest.platformProjectRoot, 'Podfile');
if (fs.existsSync(podfilePath)) {
let podSrc = fs.readFileSync(podfilePath, 'utf8');
// 若已包含任一 WechatOpenSDK 相关 pod则跳过
if (!/pod\s+['\"]WechatOpenSDK[-\w]*['\"]/.test(podSrc)) {
let injected = false;
// 优先插入到包含 use_expo_modules! 或 use_react_native! 的主 target 中
podSrc = podSrc.replace(/(target\s+'[^']+'\s+do[\s\S]*?)(\n\s*use_(expo_modules|react_native)!.*\n)/, (match, head, useLine) => {
injected = true;
return `${head}${useLine} pod 'WechatOpenSDK-XCFramework'\n`;
});
if (!injected) {
// 退化:在第一个 target 块开头插入
podSrc = podSrc.replace(/target\s+'[^']+'\s+do\s*\n/, (m) => {
injected = true;
return `${m} pod 'WechatOpenSDK-XCFramework'\n`;
});
}
if (injected) {
fs.writeFileSync(podfilePath, podSrc, 'utf8');
if (process.env.NODE_ENV !== 'production') {
console.log('[WeChatPlugin] 已在 Podfile 注入 pod \'WechatOpenSDK-XCFramework\'');
}
}
}
}
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
console.warn('[WeChatPlugin] Podfile 注入跳过:', e?.message || e);
}
}
return c;
}
]);
// iOS: 确保 Swift 工程的 Bridging Header 引入 WXApi.h
config = withDangerousMod(config, [
'ios',
(c) => {
try {
const iosDir = path.join(c.modRequest.projectRoot, 'ios');
if (fs.existsSync(iosDir)) {
const candidates = [];
const walk = (dir) => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const e of entries) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\-Bridging-Header\.h$/.test(e.name)) candidates.push(p);
}
};
walk(iosDir);
for (const headerPath of candidates) {
try {
const content = fs.readFileSync(headerPath, 'utf8');
if (!content.includes('#import "WXApi.h"')) {
fs.writeFileSync(headerPath, content.replace(/\s*$/, `\n#import "WXApi.h"\n`));
}
} catch {}
}
}
} catch {}
return c;
}
]);
return config;
};

View File

@ -0,0 +1,61 @@
/*
* react-native-wechat-lib 提供原生模块名称别名的跨平台适配
* Android 端原生模块以不同名称 'RCTWeChat'注册时
* 通过该别名机制让 JS 侧仍可通过库常用的别名'WeChat' / 'WechatLib'访问
*
* 重要应尽可能早地在应用生命周期中加载此文件
*/
import { NativeModules, Platform } from 'react-native';
(function ensureWeChatAliases() {
try {
const nm = NativeModules || {};
// 尝试多种可能的原生模块名称
const resolveCandidate = () => nm.RCTWeChat || nm.WechatLib || nm.Wechat || null;
const mod = resolveCandidate();
if (!mod) {
// 诊断:仅在未找到原生模块时打印一次日志(例如在 Expo Go 环境中)
if (!global.__WECHAT_ALIAS_WARNED__) {
global.__WECHAT_ALIAS_WARNED__ = true;
try {
const keys = Object.keys(nm || {});
console.warn(
'[wechat-alias] Native WeChat module not found on NativeModules. ' +
'If you are running in Expo Go, custom native modules are unavailable. ' +
'Please use a custom dev client or a native build. ' +
`(platform=${Platform.OS}) NativeModules keys sample: ${keys.slice(0, 50).join(', ')}`
);
} catch (_) {}
}
}
// 定义惰性 getter即使原生模块稍后注册访问时也能动态解析
const defineLazy = (key) => {
if (!(key in nm) || nm[key] == null) {
try {
Object.defineProperty(nm, key, {
configurable: true,
enumerable: true,
get() {
return resolveCandidate();
},
});
} catch (_) {
// 安静忽略异常,避免影响业务逻辑
if (!nm[key]) nm[key] = resolveCandidate();
}
}
};
defineLazy('WeChat');
defineLazy('WechatLib');
defineLazy('Wechat');
global.__WECHAT_ALIAS_READY__ = true;
} catch (e) {
// Silently ignore if anything unexpected happens here
}
})();

13
apps/fiee/.expo/README.md Normal file
View File

@ -0,0 +1,13 @@
> Why do I have a folder named ".expo" in my project?
The ".expo" folder is created when an Expo project is started using "expo start" command.
> What do the files contain?
- "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds.
- "settings.json": contains the server configuration that is used to serve the application manifest.
> Should I commit the ".expo" folder?
No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine.
Upon project creation, the ".expo" folder is already added to your ".gitignore" file.

View File

@ -0,0 +1,8 @@
{
"devices": [
{
"installationId": "c64e57c5-bceb-4532-b5fa-f222fcb53db3",
"lastUsed": 1767095061096
}
]
}

View File

@ -0,0 +1,4 @@
{
"dependencies": "bbbaca865cf1324ed12c94ca0011ee694fadafbe",
"devDependencies": "c3cd064e4923c90625f80410ad7b1852e1c59985"
}

View File

@ -0,0 +1,7 @@
** BUILD FAILED **
The following build commands failed:
ScanDependencies /Users/fiee/Library/Developer/Xcode/DerivedData/FiEELink-gezhiccgyshtyqehmskgbrjmrlen/Build/Intermediates.noindex/FiEELink.build/Debug-iphoneos/FiEELink.build/Objects-normal/arm64/NotificationForwarder.o /Users/fiee/Projects/brands-monorepo/apps/fiee/ios/FIJPushForwarder/NotificationForwarder.m normal arm64 objective-c com.apple.compilers.llvm.clang.1_0.compiler (in target 'FiEELink' from project 'FiEELink')
Building workspace FiEELink with scheme FiEELink and configuration Debug
(2 failures)

82388
apps/fiee/.expo/xcodebuild.log Normal file

File diff suppressed because one or more lines are too long

16
apps/fiee/android/.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# OSX
#
.DS_Store
# Android/IntelliJ
#
build/
.idea
.gradle
local.properties
*.iml
*.hprof
.cxx/
# Bundle artifacts
*.jsbundle

View File

@ -0,0 +1,212 @@
apply plugin: "com.android.application"
apply plugin: "org.jetbrains.kotlin.android"
apply plugin: "com.facebook.react"
def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath()
/**
* This is the configuration block to customize your React Native Android app.
* By default you don't need to apply any configuration, just uncomment the lines you need.
*/
react {
entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim())
reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc"
codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile()
enableBundleCompression = (findProperty('android.enableBundleCompression') ?: false).toBoolean()
// Use Expo CLI to bundle the app, this ensures the Metro config
// works correctly with Expo projects.
cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim())
bundleCommand = "export:embed"
/* Folders */
// The root of your project, i.e. where "package.json" lives. Default is '../..'
// root = file("../../")
// The folder where the react-native NPM package is. Default is ../../node_modules/react-native
// reactNativeDir = file("../../node_modules/react-native")
// The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen
// codegenDir = file("../../node_modules/@react-native/codegen")
/* Variants */
// The list of variants to that are debuggable. For those we're going to
// skip the bundling of the JS bundle and the assets. By default is just 'debug'.
// If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants.
// debuggableVariants = ["liteDebug", "prodDebug"]
/* Bundling */
// A list containing the node command and its flags. Default is just 'node'.
// nodeExecutableAndArgs = ["node"]
//
// The path to the CLI configuration file. Default is empty.
// bundleConfig = file(../rn-cli.config.js)
//
// The name of the generated asset file containing your JS bundle
// bundleAssetName = "MyApplication.android.bundle"
//
// The entry file for bundle generation. Default is 'index.android.js' or 'index.js'
// entryFile = file("../js/MyApplication.android.js")
//
// A list of extra flags to pass to the 'bundle' commands.
// See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle
// extraPackagerArgs = []
/* Hermes Commands */
// The hermes compiler command to run. By default it is 'hermesc'
// hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc"
//
// The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map"
// hermesFlags = ["-O", "-output-source-map"]
/* Autolinking */
autolinkLibrariesWithApp()
}
/**
* Set this to true in release builds to optimize the app using [R8](https://developer.android.com/topic/performance/app-optimization/enable-app-optimization).
*/
def enableMinifyInReleaseBuilds = (findProperty('android.enableMinifyInReleaseBuilds') ?: false).toBoolean()
/**
* The preferred build flavor of JavaScriptCore (JSC)
*
* For example, to use the international variant, you can use:
* `def jscFlavor = 'org.webkit:android-jsc-intl:+'`
*
* The international variant includes ICU i18n library and necessary data
* allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that
* give correct results when using with locales other than en-US. Note that
* this variant is about 6MiB larger per architecture than default.
*/
def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.+'
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace 'com.fiee.FLink'
defaultConfig {
applicationId 'com.fiee.FLink'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 114
versionName "1.1.4"
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'
manifestPlaceholders = [
JPUSH_APPKEY: "",
JPUSH_CHANNEL: "dev",
JPUSH_PKGNAME: "${applicationId}",
XIAOMI_APPID: "",
XIAOMI_APPKEY: "",
OPPO_APPID: "",
OPPO_APPKEY: "",
OPPO_APPSECRET: "",
VIVO_APPID: "",
VIVO_APPKEY: "",
MEIZU_APPID: "",
MEIZU_APPKEY: "",
HONOR_APPID: ""
]}\""
}
signingConfigs {
debug {
storeFile file('debug.keystore')
storePassword 'android'
keyAlias 'androiddebugkey'
keyPassword 'android'
}
}
buildTypes {
debug {
signingConfig signingConfigs.debug
}
release {
// Caution! In production, you need to generate your own keystore file.
// see https://reactnative.dev/docs/signed-apk-android.
signingConfig signingConfigs.debug
def enableShrinkResources = findProperty('android.enableShrinkResourcesInReleaseBuilds') ?: 'false'
shrinkResources enableShrinkResources.toBoolean()
minifyEnabled enableMinifyInReleaseBuilds
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
def enablePngCrunchInRelease = findProperty('android.enablePngCrunchInReleaseBuilds') ?: 'true'
crunchPngs enablePngCrunchInRelease.toBoolean()
}
}
packagingOptions {
jniLibs {
def enableLegacyPackaging = findProperty('expo.useLegacyPackaging') ?: 'false'
useLegacyPackaging enableLegacyPackaging.toBoolean()
}
}
androidResources {
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
// Apply static values from `gradle.properties` to the `android.packagingOptions`
// Accepts values in comma delimited lists, example:
// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini
["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop ->
// Split option: 'foo,bar' -> ['foo', 'bar']
def options = (findProperty("android.packagingOptions.$prop") ?: "").split(",");
// Trim all elements in place.
for (i in 0..<options.size()) options[i] = options[i].trim();
// `[] - ""` is essentially `[""].filter(Boolean)` removing all empty strings.
options -= ""
if (options.length > 0) {
println "android.packagingOptions.$prop += $options ($options.length)"
// Ex: android.packagingOptions.pickFirsts += '**/SCCS/**'
options.each {
android.packagingOptions[prop] += it
}
}
}
dependencies {
// The version of react-native is set by the React Native Gradle Plugin
implementation("com.facebook.react:react-android")
def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true";
def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true";
def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true";
if (isGifEnabled) {
// For animated gif support
implementation("com.facebook.fresco:animated-gif:${expoLibs.versions.fresco.get()}")
// JPush
implementation project(':jpush-react-native')
implementation project(':jcore-react-native')
implementation 'cn.jiguang.sdk.plugin:xiaomi:5.9.0'
implementation 'cn.jiguang.sdk.plugin:oppo:5.9.0'
implementation 'cn.jiguang.sdk.plugin:vivo:5.9.0'
implementation 'cn.jiguang.sdk.plugin:honor:5.9.0'
//
implementation 'cn.jiguang.sdk.plugin:meizu:5.9.0'
//
implementation 'cn.jiguang.sdk.plugin:huawei:5.9.0'
implementation 'com.huawei.hms:push:6.13.0.300'
// FCM
implementation 'cn.jiguang.sdk.plugin:fcm:5.9.0'
}
if (isWebpEnabled) {
// For webp support
implementation("com.facebook.fresco:webpsupport:${expoLibs.versions.fresco.get()}")
if (isWebpAnimatedEnabled) {
// Animated webp support
implementation("com.facebook.fresco:animated-webp:${expoLibs.versions.fresco.get()}")
}
}
if (hermesEnabled.toBoolean()) {
implementation("com.facebook.react:hermes-android")
} else {
implementation jscFlavor
}
}

Binary file not shown.

View File

@ -0,0 +1,26 @@
# Add project specific ProGuard rules here.
# By default, the flags in this file are appended to flags specified
# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt
# You can edit the include path and order by changing the proguardFiles
# directive in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# react-native-reanimated
-keep class com.swmansion.reanimated.** { *; }
-keep class com.facebook.react.turbomodule.** { *; }
# Add any project specific keep options here:
// [signing-config-plugin] huawei proguard start
-ignorewarnings
-keepattributes *Annotation*
-keepattributes Exceptions
-keepattributes InnerClasses
-keepattributes Signature
-keepattributes SourceFile,LineNumberTable
-keep class com.hianalytics.android.** { *; }
-keep class com.huawei.updatesdk.** { *; }
-keep class com.huawei.hms.** { *; }
// [signing-config-plugin] huawei proguard end

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<application android:usesCleartextTraffic="true" tools:targetApi="28" tools:ignore="GoogleAppIndexingWarning" tools:replace="android:usesCleartextTraffic" />
</manifest>

View File

@ -0,0 +1,48 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" tools:node="remove"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.GET_TASKS" tools:node="remove"/>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" tools:node="remove"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_PHONE_STATE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"/>
</intent>
</queries>
<application android:name=".MainApplication" android:label="@string/app_name" android:icon="@mipmap/ic_launcher" android:allowBackup="true" android:theme="@style/AppTheme" android:supportsRtl="true" android:enableOnBackInvokedCallback="false" android:usesCleartextTraffic="false">
<meta-data android:name="JPUSH_APPKEY" android:value="${JPUSH_APPKEY}"/>
<meta-data android:name="JPUSH_CHANNEL" android:value="${JPUSH_CHANNEL}"/>
<meta-data android:name="com.baidu.lbsapi.API_KEY" android:value="w5SWSj0Z69I9gyy3w3I4On2g3tvfYrJs"/>
<meta-data android:name="expo.modules.updates.ENABLED" android:value="true"/>
<meta-data android:name="expo.modules.updates.EXPO_RUNTIME_VERSION" android:value="@string/expo_runtime_version"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="NEVER"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATE_URL" android:value="undefined/api/manifest"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="fieeoa"/>
<data android:scheme="exp+fiee-oa"/>
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,75 @@
package com.fiee.FLink
import expo.modules.splashscreen.SplashScreenManager
import android.os.Build
import android.os.Bundle
import com.facebook.react.ReactActivity
import com.facebook.react.ReactActivityDelegate
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
import com.facebook.react.defaults.DefaultReactActivityDelegate
import expo.modules.ReactActivityDelegateWrapper
class MainActivity : ReactActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Set the theme to AppTheme BEFORE onCreate to support
// coloring the background, status bar, and navigation bar.
// This is required for expo-splash-screen.
// setTheme(R.style.AppTheme);
// @generated begin expo-splashscreen - expo prebuild (DO NOT MODIFY) sync-f3ff59a738c56c9a6119210cb55f0b613eb8b6af
SplashScreenManager.registerOnActivity(this)
// @generated end expo-splashscreen
savedInstanceState?.clear()
super.onCreate(null);
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
override fun getMainComponentName(): String = "main"
/**
* Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
* which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
*/
override fun createReactActivityDelegate(): ReactActivityDelegate {
return ReactActivityDelegateWrapper(
this,
BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
object : DefaultReactActivityDelegate(
this,
mainComponentName,
fabricEnabled
){})
}
/**
* Align the back button behavior with Android S
* where moving root activities to background instead of finishing activities.
* @see <a href="https://developer.android.com/reference/android/app/Activity#onBackPressed()">onBackPressed</a>
*/
override fun invokeDefaultOnBackPressed() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
if (!moveTaskToBack(false)) {
// For non-root activities, use the default implementation to finish them.
super.invokeDefaultOnBackPressed()
}
return
}
// Use the default back button implementation on Android S
// because it's doing more than [Activity.moveTaskToBack] in fact.
super.invokeDefaultOnBackPressed()
}
/**
* 拦截状态保存防止系统在后台杀进程时写入 Fragment 状态
* 结合 onCreate 中的 clear实现双重保险
*/
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.clear()
}
}

View File

@ -0,0 +1,56 @@
package com.fiee.FLink
import android.app.Application
import android.content.res.Configuration
import com.facebook.react.PackageList
import com.facebook.react.ReactApplication
import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative
import com.facebook.react.ReactNativeHost
import com.facebook.react.ReactPackage
import com.facebook.react.ReactHost
import com.facebook.react.common.ReleaseLevel
import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint
import com.facebook.react.defaults.DefaultReactNativeHost
import expo.modules.ApplicationLifecycleDispatcher
import expo.modules.ReactNativeHostWrapper
class MainApplication : Application(), ReactApplication {
override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
this,
object : DefaultReactNativeHost(this) {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
// Packages that cannot be autolinked yet can be added manually here, for example:
// add(MyReactNativePackage())
}
override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
}
)
override val reactHost: ReactHost
get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost)
override fun onCreate() {
super.onCreate()
DefaultNewArchitectureEntryPoint.releaseLevel = try {
ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase())
} catch (e: IllegalArgumentException) {
ReleaseLevel.STABLE
}
loadReactNative(this)
ApplicationLifecycleDispatcher.onApplicationCreate(this)
}
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@ -0,0 +1,6 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splashscreen_background"/>
<item>
<bitmap android:gravity="center" android:src="@drawable/splashscreen_logo"/>
</item>
</layer-list>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Copyright (C) 2014 The Android Open Source Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material"
android:insetRight="@dimen/abc_edit_text_inset_horizontal_material"
android:insetTop="@dimen/abc_edit_text_inset_top_material"
android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"
>
<selector>
<!--
This file is a copy of abc_edit_text_material (https://bit.ly/3k8fX7I).
The item below with state_pressed="false" and state_focused="false" causes a NullPointerException.
NullPointerException:tempt to invoke virtual method 'android.graphics.drawable.Drawable android.graphics.drawable.Drawable$ConstantState.newDrawable(android.content.res.Resources)'
<item android:state_pressed="false" android:state_focused="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
For more info, see https://bit.ly/3CdLStv (react-native/pull/29452) and https://bit.ly/3nxOMoR.
-->
<item android:state_enabled="false" android:drawable="@drawable/abc_textfield_default_mtrl_alpha"/>
<item android:drawable="@drawable/abc_textfield_activated_mtrl_alpha"/>
</selector>
</inset>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -0,0 +1 @@
<resources/>

View File

@ -0,0 +1,6 @@
<resources>
<color name="splashscreen_background">#FFFFFF</color>
<color name="iconBackground">#ffffff</color>
<color name="colorPrimary">#023c69</color>
<color name="colorPrimaryDark">#ffffff</color>
</resources>

View File

@ -0,0 +1,7 @@
<resources>
<string name="app_name">FiEELink</string>
<string name="expo_system_ui_user_interface_style" translatable="false">light</string>
<string name="expo_splash_screen_resize_mode" translatable="false">cover</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="expo_runtime_version">1.1.4</string>
</resources>

View File

@ -0,0 +1,14 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<style name="AppTheme" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">true</item>
<item name="android:editTextBackground">@drawable/rn_edit_text_material</item>
<item name="colorPrimary">@color/colorPrimary</item>
<item name="android:statusBarColor">#ffffff</item>
</style>
<style name="Theme.App.SplashScreen" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/splashscreen_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splashscreen_logo</item>
<item name="postSplashScreenTheme">@style/AppTheme</item>
<item name="android:windowSplashScreenBehavior">icon_preferred</item>
</style>
</resources>

View File

@ -0,0 +1,28 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
maven { url "https://developer.huawei.com/repo/"
maven { url "https://developer.hihonor.com/repo/" }}}
dependencies {
classpath('com.android.tools.build:gradle:8.8.2')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io'
maven { url "https://developer.huawei.com/repo/"
maven { url "https://developer.hihonor.com/repo/" }}}
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"

View File

@ -0,0 +1,65 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
# Use this property to specify which architecture you want to build.
# You can also override it from the CLI using
# ./gradlew <task> -PreactNativeArchitectures=x86_64
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
# Use this property to enable support to the new architecture.
# This will allow you to use TurboModules and the Fabric render in
# your application. You should enable this flag either if you want
# to write custom TurboModules/Fabric components OR use libraries that
# are providing them.
newArchEnabled=true
# Use this property to enable or disable the Hermes JS engine.
# If set to false, you will be using JSC instead.
hermesEnabled=true
# Use this property to enable edge-to-edge display support.
# This allows your app to draw behind system bars for an immersive UI.
# Note: Only works with ReactActivity and should not be used with custom Activity.
edgeToEdgeEnabled=true
# Enable GIF support in React Native images (~200 B increase)
expo.gif.enabled=true
# Enable webp support in React Native images (~85 KB increase)
expo.webp.enabled=true
# Enable animated webp support (~3.4 MB increase)
# Disabled by default because iOS doesn't support animated webp
expo.webp.animated=false
# Enable network inspector
EX_DEV_CLIENT_NETWORK_INSPECTOR=true
# Use legacy packaging to compress native libraries in the resulting APK.
expo.useLegacyPackaging=false
# Specifies whether the app is configured to use edge-to-edge via the app config or plugin
# WARNING: This property has been deprecated and will be removed in Expo SDK 55. Use `edgeToEdgeEnabled` or `react.edgeToEdgeEnabled` to determine whether the project is using edge-to-edge.
expo.edgeToEdgeEnabled=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
apps/fiee/android/gradlew vendored Executable file
View File

@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
apps/fiee/android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@ -0,0 +1,45 @@
pluginManagement {
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = 'FiEELink'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
include ':jpush-react-native'
project(':jpush-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/jpush-react-native/android')
include ':jcore-react-native'
project(':jcore-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/jcore-react-native/android')

Some files were not shown because too many files have changed in this diff Show More