first commit
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
node_modules
|
||||
506
NativePlugins/baidu-location-react-native/README.md
Normal 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,海外默认 WGS84;iOS 在中国范围内将 WGS84 转为 GCJ02,海外保持 WGS84 原样;Android 原生插件在启动前进行一次区域判断并设置坐标系
|
||||
- 如需调用要求 BD09LL 的百度地图服务(例如部分 Web 服务接口),请在调用前将坐标转换为 BD09LL;定位侧输出与地图渲染保持独立,不互相回写或干扰
|
||||
146
NativePlugins/baidu-location-react-native/index.d.ts
vendored
Normal 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;
|
||||
182
NativePlugins/baidu-location-react-native/index.js
Normal 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,
|
||||
};
|
||||
29
NativePlugins/baidu-location-react-native/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
dependency: {
|
||||
platforms: {
|
||||
android: {
|
||||
sourceDir: 'android',
|
||||
},
|
||||
ios: null, // 暂无 iOS
|
||||
},
|
||||
},
|
||||
};
|
||||
129
NativePlugins/disable-saved-state-plugin/app.plugin.js
Normal 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;
|
||||
224
NativePlugins/jpush-expo-plugin/README.md
Normal 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 与 iOS(iOS 需使用自定义开发客户端或原生构建包)
|
||||
- 依赖:`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` 与构建流程。
|
||||
542
NativePlugins/jpush-expo-plugin/app.plugin.js
Normal 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;
|
||||
};
|
||||
BIN
NativePlugins/jpush-expo-plugin/small-icons/hdpi36.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
NativePlugins/jpush-expo-plugin/small-icons/mdpi24.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
NativePlugins/jpush-expo-plugin/small-icons/xhdpi48.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
NativePlugins/jpush-expo-plugin/small-icons/xxhdpi72.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
NativePlugins/jpush-expo-plugin/small-icons/xxxhdpi96.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
@ -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"}}]}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
88
NativePlugins/live-activity-react-native/USAGE.md
Normal 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.1,iOS Deployment Target ≥ 16.1;若仍不可见,需要在 Apple Developer 后台先为 App ID 启用该能力。
|
||||
|
||||
|
||||
## 五、移除/禁用
|
||||
|
||||
- 如果暂时不想在业务中使用,只需:
|
||||
- 从业务代码移除 import 与调用(例如 clockIn.jsx 中的 LiveActivity 调用)
|
||||
- 保留 app.config.js 中插件项,便于后续随时启用
|
||||
49
NativePlugins/live-activity-react-native/index.js
Normal 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;
|
||||
10
NativePlugins/live-activity-react-native/package.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
@ -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;
|
||||
}]);
|
||||
};
|
||||
@ -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;
|
||||
});
|
||||
};
|
||||
58
NativePlugins/manifest-permission-cleaner/app.plugin.js
Normal 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;
|
||||
});
|
||||
};
|
||||
124
NativePlugins/notification-forwarder-plugin/README.md
Normal file
@ -0,0 +1,124 @@
|
||||
# notification-forwarder-plugin
|
||||
|
||||
轻量 iOS 原生插件,用于在 Expo(Bare)项目中统一、稳定地将 `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 # Objective‑C:事件转发器 + 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` 插件空分组崩溃。
|
||||
- 平台范围:仅 iOS;Android 不做任何改动。
|
||||
|
||||
## 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 初始化后仍能收到。
|
||||
|
||||
## 兼容性
|
||||
- iOS:12+(针对 iOS 14+ 合并横幅/列表展示选项;更低版本使用 Alert)。
|
||||
- RN:基于 `RCTEventEmitter`,与 `NativeEventEmitter` 订阅方式兼容。
|
||||
|
||||
## 版本与维护
|
||||
- 插件版本:`1.0.0`
|
||||
- 修改范围最小化:不、更、不会替换你的现有通知处理逻辑,只做事件补充与转发。
|
||||
|
||||
---
|
||||
如需将该插件在团队文档(如打包指南)中补充说明,可引用本 README 的“原理说明”“JS 使用示例”和“验证清单”三节,帮助快速对齐行为与期望。
|
||||
63
NativePlugins/notification-forwarder-plugin/app.plugin.js
Normal 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');
|
||||
216
NativePlugins/react-native-alhspan/README.md
Normal 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>
|
||||
```
|
||||
|
||||
## 能力与策略
|
||||
|
||||
- ALHSpan(AdjustLineHeightSpan):移除字体额外内边距并对称分配到 `ascent/descent`,确保测量与绘制一致、无裁切且几何居中。
|
||||
- InkCenterSpan(ReplacementSpan):在存在明显下行部或墨水上下失衡时进行温和平移,夹紧在 `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` 存在时,无点击范围保持父级手势;有点击范围仅片段触发子事件。
|
||||
- 性能:1–5k 字符与 ≤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
|
||||
38
NativePlugins/react-native-alhspan/index.d.ts
vendored
Normal 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;
|
||||
120
NativePlugins/react-native-alhspan/index.js
vendored
Normal 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',
|
||||
},
|
||||
});
|
||||
12
NativePlugins/react-native-alhspan/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
10
NativePlugins/react-native-alhspan/react-native.config.js
Normal file
@ -0,0 +1,10 @@
|
||||
module.exports = {
|
||||
dependency: {
|
||||
platforms: {
|
||||
android: {
|
||||
sourceDir: 'android',
|
||||
},
|
||||
ios: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
BIN
NativePlugins/signing-config-plugin/FLink.keystore
Normal file
188
NativePlugins/signing-config-plugin/README.md
Normal 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` 的打包指南与你们的发布流程进行调整。
|
||||
212
NativePlugins/signing-config-plugin/app.plugin.js
Normal 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);
|
||||
6
NativePlugins/signing-config-plugin/keystore.properties
Normal 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
|
||||
145
NativePlugins/splash-branding-plugin/README.md
Normal 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)
|
||||
- 平台:Android(iOS 启动屏请继续使用 `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`
|
||||
|
||||
---
|
||||
|
||||
如需定制更多展示效果(如居中、不同分辨率资源、多语言版本),可在本插件基础上扩展资源生成逻辑或增加配置项。
|
||||
116
NativePlugins/splash-branding-plugin/app.plugin.js
Normal 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');
|
||||
187
NativePlugins/wechat-lig-config-plugin/README.md
Normal file
@ -0,0 +1,187 @@
|
||||
# WeChat Lib 配置插件使用手册(wechat-lig-config-plugin)
|
||||
|
||||
> 本插件用于 Expo React Native 项目中自动化接入微信 SDK(react-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` 与微信开放平台文档进行验证。
|
||||
344
NativePlugins/wechat-lig-config-plugin/app.plugin.js
Normal 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;
|
||||
};
|
||||
@ -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
@ -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.
|
||||
8
apps/fiee/.expo/devices.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"devices": [
|
||||
{
|
||||
"installationId": "c64e57c5-bceb-4532-b5fa-f222fcb53db3",
|
||||
"lastUsed": 1767095061096
|
||||
}
|
||||
]
|
||||
}
|
||||
4
apps/fiee/.expo/prebuild/cached-packages.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"dependencies": "bbbaca865cf1324ed12c94ca0011ee694fadafbe",
|
||||
"devDependencies": "c3cd064e4923c90625f80410ad7b1852e1c59985"
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 886 KiB |
7
apps/fiee/.expo/xcodebuild-error.log
Normal 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
16
apps/fiee/android/.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# OSX
|
||||
#
|
||||
.DS_Store
|
||||
|
||||
# Android/IntelliJ
|
||||
#
|
||||
build/
|
||||
.idea
|
||||
.gradle
|
||||
local.properties
|
||||
*.iml
|
||||
*.hprof
|
||||
.cxx/
|
||||
|
||||
# Bundle artifacts
|
||||
*.jsbundle
|
||||
212
apps/fiee/android/app/build.gradle
Normal 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
|
||||
}
|
||||
}
|
||||
BIN
apps/fiee/android/app/debug.keystore
Normal file
26
apps/fiee/android/app/proguard-rules.pro
vendored
Normal 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
|
||||
7
apps/fiee/android/app/src/debug/AndroidManifest.xml
Normal 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>
|
||||
@ -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>
|
||||
48
apps/fiee/android/app/src/main/AndroidManifest.xml
Normal 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>
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 86 KiB |
@ -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>
|
||||
@ -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>
|
||||
BIN
apps/fiee/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/fiee/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/fiee/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 82 KiB |
@ -0,0 +1 @@
|
||||
<resources/>
|
||||
6
apps/fiee/android/app/src/main/res/values/colors.xml
Normal 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>
|
||||
7
apps/fiee/android/app/src/main/res/values/strings.xml
Normal 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>
|
||||
14
apps/fiee/android/app/src/main/res/values/styles.xml
Normal 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>
|
||||
28
apps/fiee/android/build.gradle
Normal 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"
|
||||
65
apps/fiee/android/gradle.properties
Normal 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
|
||||
BIN
apps/fiee/android/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
7
apps/fiee/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
@ -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
@ -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
|
||||
45
apps/fiee/android/settings.gradle
Normal 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')
|
||||