已合并
test: 添加0.61 iap 和 image-editor 测试Demo #2006
hisakoTT创建于 11 天前
test: 添加0.61 iap 和 image-editor 测试Demo #2006
已合并
hisakoTT创建于 11 天前
16 个文件变更+1974-6
@@ -0,0 +1,99 @@
1+import React, { useState } from 'react';
2+import { View, Text, TouchableOpacity, Modal, FlatList, StyleSheet } from 'react-native';
3+ 
4+type DropdownItem = { id: string | number; type: string };
5+ 
6+type CustomDropdownProps = {
7+ data: DropdownItem[];
8+ onSelect: (item: DropdownItem) => void;
9+ placeholder?: string;
10+};
11+ 
12+export const CustomDropdown: React.FC<CustomDropdownProps> = ({ data, onSelect, placeholder = 'Select...' }): JSX.Element => {
13+ const [visible, setVisible] = useState<boolean>(false);
14+ const [selected, setSelected] = useState<DropdownItem | null>(null);
15+ 
16+ const handleSelect = (item: DropdownItem) => {
17+ setSelected(item);
18+ onSelect(item);
19+ setVisible(false);
20+ };
21+ 
22+ return (
23+ <View style={styles.container}>
24+ <TouchableOpacity style={styles.dropdownButton} onPress={() => setVisible(true)}>
25+ <Text style={styles.buttonText}>{selected ? `${'类型: ' + selected.type + ' id: ' + selected.id}` : placeholder}</Text>
26+ <Text style={styles.arrow}>▼</Text>
27+ </TouchableOpacity>
28+ 
29+ <Modal transparent={true} visible={visible} onRequestClose={() => setVisible(false)}>
30+ <TouchableOpacity style={styles.modalOverlay} onPress={() => setVisible(false)}>
31+ <View style={styles.modalContent}>
32+ <FlatList
33+ data={data}
34+ keyExtractor={(item) => String(item.id)}
35+ renderItem={({ item }) => (
36+ <TouchableOpacity style={styles.option} onPress={() => handleSelect(item)}>
37+ <Text style={styles.optionText}>{'类型: ' + item.type} {'id: ' + item.id}</Text>
38+ </TouchableOpacity>
39+ )}
40+ />
41+ </View>
42+ </TouchableOpacity>
43+ </Modal>
44+ </View>
45+ );
46+};
47+ 
48+const styles = StyleSheet.create({
49+ container: {
50+ margin: 10,
51+ },
52+ 
53+ dropdownButton: {
54+ flexDirection: 'row',
55+ justifyContent: 'space-between',
56+ alignItems: 'center',
57+ padding: 10,
58+ borderWidth: 1,
59+ borderColor: '#666',
60+ borderRadius: 6,
61+ backgroundColor: '#fff',
62+ marginVertical: 4,
63+ },
64+ 
65+ buttonText: {
66+ fontSize: 14,
67+ color: '#110b0bff',
68+ },
69+ 
70+ arrow: {
71+ fontSize: 12,
72+ color: '#0a0a0aff',
73+ },
74+ 
75+ modalOverlay: {
76+ flex: 1,
77+ backgroundColor: '#333',
78+ justifyContent: 'center',
79+ alignItems: 'center',
80+ },
81+ 
82+ modalContent: {
83+ backgroundColor: '#f0ececff',
84+ borderRadius: 8,
85+ padding: 10,
86+ width: '80%',
87+ maxHeight: 300,
88+ },
89+ 
90+ option: {
91+ padding: 15,
92+ borderBottomWidth: 1,
93+ borderBottomColor: '#070606ff',
94+ },
95+ 
96+ optionText: {
97+ fontSize: 16,
98+ },
99+});
@@ -0,0 +1,861 @@
1+import React from 'react';
2+import {
3+ View,
4+ Text,
5+ StyleSheet,
6+ Button,
7+ ScrollView,
8+ Alert,
9+ EmitterSubscription
10+} from 'react-native';
11+import {
12+ finishTransaction,
13+ initConnection,
14+ endConnection,
15+ getProducts,
16+ getSubscriptions,
17+ getAvailablePurchases,
18+ requestPurchase,
19+ requestSubscription,
20+ getPurchaseHistory,
21+ getAppTransaction,
22+ deepLinkToSubscriptions,
23+ purchaseUpdatedListener,
24+ purchaseErrorListener,
25+ Purchase,
26+ PurchaseError,
27+ Product
28+} from 'react-native-iap';
29+import { CustomDropdown } from './CustomFropdown';
30+import { Tester, TestCase, TestSuite } from '@rnoh/testerino';
31+import TestPage from "./test"
32+ 
33+class AppDemo extends React.Component {
34+ purchaseUpdatedSubscription: EmitterSubscription | null;
35+ purchaseErrorSubscription: EmitterSubscription | null;
36+ state = {
37+ log: '',
38+ connected: false,
39+ packageName: '',
40+ productType: 'inapp',
41+ skuIds: 'sku_001,sku_002',
42+ purchaseToken: '',
43+ 
44+ productList: [] as Product[],
45+ /** 刷新失败购买缓存结果:null=未点击,true=成功,false=失败(用于对应按钮下方渲染) */
46+ flushPendingResult: null as boolean | null,
47+ /** 按类型获取商品是否请求中(用于对应按钮旁渲染) */
48+ getItemsByTypeLoading: false,
49+ /** 开始监听结果:null=未点击,'ok'=成功,'fail'=失败 */
50+ UpdatedlisteningStatus: null as string | null,
51+ ErrorlisteningStatus: null as string | null,
52+ /** 未完成订单列表(用于完成交易功能) */
53+ availablePurchases: [] as ReadonlyArray<Record<string, unknown>>,
54+ /** 查询未完成订单是否请求中 */
55+ availablePurchasesLoading: false,
56+ /** 完成交易是否请求中 */
57+ finishTransactionLoading: false,
58+ /** 购买历史记录 */
59+ purchaseHistory: [] as ReadonlyArray<Record<string, unknown>>,
60+ /** 应用交易信息 */
61+ appTransaction: null as Record<string, unknown> | null,
62+ /** 订阅管理页面跳转 SKU */
63+ subscriptionSku: 'SubProduct01001',
64+ list: [
65+ { id: 'SubProduct01002', type: 'inapp' },
66+ { id: 'ohos_consume_002', type: 'inapp' },
67+ { id: 'SubProduct01001', type: 'subs' },
68+ { id: 'ohos_consume_001', type: 'inapp' },
69+ ],
70+ showTestPage: false
71+ };
72+ /** 从商品列表选中一项用于购买:填入 id、类型并直接发起购买 */
73+ selectProductForPurchase = (productId: string, type?: string) => {
74+ const skuIds = String(productId).trim();
75+ const productType =
76+ type != null && String(type).trim()
77+ ? String(type).trim()
78+ : this.state.productType;
79+ this.setState({ skuIds, productType }, () => this.buyItemByType());
80+ console.log(`已选中并发起购买: ${productId} (${productType})`);
81+ };
82+ 
83+ appendLog = (msg: string) => {
84+ this.setState((prev: { log: string }) => ({
85+ log: prev.log + `\n ${msg}`,
86+ }));
87+ };
88+ 
89+ // 初始化 IAP 连接(对应 IAP Kit:createInstance + queryEnvironmentStatus)
90+ initConnection = async () => {
91+ try {
92+ const ok = await initConnection();
93+ this.setState({ connected: ok });
94+ console.log(`initConnection: ${ok ? '成功' : '失败'}`);
95+ } catch (e) {
96+ console.log(`initConnection 异常: ${String(e)}`);
97+ }
98+ };
99+ 
100+ // 结束 IAP 连接(释放 IapInstance,不再调用 IAP 接口)
101+ endConnection = async () => {
102+ try {
103+ const ok = await endConnection();
104+ this.setState({ connected: false });
105+ console.log(`endConnection: ${ok ? '成功' : '失败'}`);
106+ } catch (e) {
107+ console.log(`endConnection 异常: ${String(e)}`);
108+ }
109+ };
110+ 
111+ // 按类型获取商品信息:使用「商品类型」+「商品 ID 列表」,对应 IAP Kit queryProducts
112+ getItemsByType = async () => {
113+ const productIds = this.state.skuIds
114+ .split(',')
115+ .map(s => s.trim())
116+ .filter(Boolean);
117+ this.setState({ getItemsByTypeLoading: true });
118+ try {
119+ const type = this.state.productType;
120+ const params = { skus: [productIds] };
cpf-manager
cpf-managercpf-manager11 天前

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】getProducts/getSubscriptions 参数 skus 嵌套数组导致查询失败

● 问题:在 getItemsByType 方法中,productIds 已经是 string[] 类型(由 this.state.skuIds.split(',').map(s => s.trim()).filter(Boolean) 生成),但构造 params 时使用了 const params = { skus: [productIds] },多包了一层数组。这会导致 params.skus 变成 string[][] 嵌套数组(例如 [['sku_001', 'sku_002']]),而不是 react-native-iap API 期望的 string[](即 ['sku_001', 'sku_002'])。

根据 react-native-iap 的 API 定义(src/iap.ts),getProducts 的签名为 ({ skus: string[] }): Promise<Array<Product>>,且入口校验 if (!skus?.length) 会通过(因为 [['sku_001', 'sku_002']].length === 1),但实际传递给鸿蒙原生模块 Iap.getProducts({skus}) 的是嵌套数组,原生模块无法正确解析 SKU 列表。

● 影响:严重。当用户点击"按类型获取商品"按钮时(默认 skuIds'sku_001,sku_002'),由于 params.skus[['sku_001', 'sku_002']] 而非 ['sku_001', 'sku_002'],原生模块接收到的不是字符串数组而是嵌套数组,导致商品查询失败或返回空结果,测试用例的核心功能无法正常运行。

同一 PR 的 test.tsxgetSubscriptions({ skus: ['SubProduct01001'] })getProducts({ skus: ['ohos_consume_002'] }) 的正确用法证明了 API 期望 skusstring[] 而非 string[][]

● 建议:移除多余的数组包装,直接使用 productIds

const params = { skus: productIds };
likedislike
hisakoTT
11 天前 评论:
121+ let items =
122+ type === 'subs' || type === 'non_renewable'
123+ ? await getSubscriptions(params)
124+ : await getProducts(params);
125+ const list = Array.isArray(items)
126+ ? (items as Record<string, unknown>[])
127+ : [];
128+ this.setState({ productList: list, getItemsByTypeLoading: false });
129+ const successMsg = `按类型获取商品(类型=${type}, ID列表=${productIds.join(
130+ ', ',
131+ )}): ${list.length} 个`;
132+ console.log(successMsg);
133+ if (list.length > 0) {
134+ list.forEach((item, i) => {
135+ console.log('商品信息: '+JSON.stringify(item));
136+ });
137+ }
138+ Alert.alert(
139+ '查询成功',
140+ `已获取 ${list.length} 个商品信息,可在下方列表中查看并购买。`,
141+ );
142+ } catch (e) {
143+ this.setState({ productList: [], getItemsByTypeLoading: false });
144+ const errMsg = `按类型获取商品 异常: ${JSON.stringify(e)}`;
145+ console.log(errMsg);
146+ Alert.alert('查询失败', '获取商品列表失败,请检查网络或商品 ID 后重试。');
147+ }
148+ };
149+ 
150+ //查询未完成订单(对应 getAvailablePurchases,用于完成交易前选择一条记录)
151+ fetchAvailablePurchases = async () => {
152+ try {
153+ const list = (await getAvailablePurchases?.({})) ?? [];
154+ const arr = Array.isArray(list)
155+ ? (list as Record<string, unknown>[])
156+ : [];
157+ this.setState({
158+ availablePurchases: arr,
159+ availablePurchasesLoading: false,
160+ });
161+ console.log(`getAvailablePurchases: ${arr.length} 条`);
162+ console.log(
163+ `getAvailablePurchases list 是: ${JSON.stringify(
164+ list,
165+ )},${typeof list},${typeof arr} 条`,
166+ );
167+ if (JSON.stringify(list) == '{}') {
168+ Alert.alert('查询结果', '尚未初始化。');
169+ } else if (arr.length <= 0) {
170+ Alert.alert('查询结果', '当前没有未完成的订单。');
171+ }
172+ } catch (e) {
173+ this.setState({
174+ availablePurchases: [],
175+ availablePurchasesLoading: false,
176+ });
177+ console.log(`getAvailablePurchases 异常: ${String(e)}`);
178+ Alert.alert('查询失败', JSON.stringify(e));
179+ }
180+ };
181+ 
182+ // 购买商品:使用「商品类型」+「商品 ID 列表」(取第一个为 productId),对应 IAP Kit createPurchase
183+ buyItemByType = async () => {
184+ const skus = this.state.skuIds
185+ .split(',')
186+ .map(s => s.trim())
187+ .filter(Boolean);
188+ if (skus.length === 0) {
189+ Alert.alert('提示', '请至少填写一个商品 ID');
190+ return;
191+ }
192+ const productId = skus[0];
193+ const type = this.state.productType;
194+ const isSubscription = type === 'subs' || type === 'non_renewable';
195+ const request = {
196+ sku: productId,
197+ appAccountToken:'xxxxx-xxx-xxxxx',
198+ quantity: 10,
199+ promotionalOfferId: ''
200+ };
201+ try {
202+ const result = isSubscription
203+ ? await requestSubscription(request)
204+ : await requestPurchase(request);
205+ const resultStr = JSON.stringify(result);
206+ console.log(
207+ `${
208+ isSubscription ? 'requestSubscription' : 'requestPurchase'
209+ } 结果: ${resultStr}`,
210+ );
211+ const token =
212+ (result as { purchaseToken?: string })?.purchaseToken ??
213+ (result as { orderId?: string })?.orderId;
214+ if (result) {
215+ this.setState({ purchaseToken: String(token) });
216+ console.log('购买成功,点击「确认购买」确认收货');
217+ Alert.alert('支付成功', '支付已完成,点击「确认购买」确认收货。');
218+ } else {
219+ Alert.alert('购买完成', '支付未完成,请稍后重试或检查网络与支付方式。');
220+ }
221+ } catch (e) {
222+ const errMsg = `购买异常: ${JSON.stringify(e)}`;
223+ console.log(errMsg);
224+ Alert.alert('支付失败', '支付未完成,请稍后重试或检查网络与支付方式。');
225+ }
226+ };
227+ 
228+ // 确认购买(对应 IAP Kit:finishPurchase,非消耗型/订阅;购买成功时已自动调用,此处用于手动重试)
229+ acknowledgePurchase = async () => {
230+ try {
231+ for (let i = 0; i < this.state.availablePurchases.length; i++)
232+ {
233+ const purchase = this.state.availablePurchases[i]
234+ console.debug('iap => '+JSON.stringify(purchase.productTypeIos))
235+ const isConsumable = purchase.productTypeIos === 'inapp';
236+ const result = await finishTransaction({ purchase, isConsumable });
237+ console.debug('iap => '+JSON.stringify(result))
238+ if (result) {
239+ Alert.alert('确认成功', '订单已确认,商品权益已生效。');
cpf-manager
cpf-managercpf-manager11 天前

【AI-Review】【一般】【基础代码问题】【代码逻辑错误】acknowledgePurchase 循环中调用 Alert.alert 导致多次提示被覆盖

● 问题:acknowledgePurchase 方法在 for 循环中对 this.state.availablePurchases 的每个订单调用 await finishTransaction(...),然后在循环体内直接调用 Alert.alert('确认成功', ...)Alert.alert('确认失败', ...)。当存在多条未完成订单时,循环会连续多次调用 Alert.alert,而 Alert.alert 是异步的模态对话框,后调用的会覆盖前面的,用户最终只能看到最后一个订单的结果提示。

触发路径:用户点击"确认完成所有订单"按钮 → fetchAvailablePurchases 已查询到 N 条订单 → 进入 for 循环 → 对每条订单调用 finishTransactionAlert.alert → N 次 Alert 调用互相覆盖 → 用户只看到最后一条订单的结果。

● 影响:一般。当有多条未完成订单且部分成功、部分失败时,用户无法从 UI 上得知每条订单的处理结果,只看到最后一个订单的提示,影响测试用例对 finishTransaction 行为的验证。

● 建议:将循环中的 Alert.alert 移到循环外,汇总结果后统一提示。例如统计成功/失败数量后弹出一次 Alert:

let successCount = 0;
let failCount = 0;
for (let i = 0; i < this.state.availablePurchases.length; i++) {
  const purchase = this.state.availablePurchases[i];
  const isConsumable = purchase.productTypeIos === 'inapp';
  try {
    const result = await finishTransaction({ purchase, isConsumable });
    if (result) { successCount++; } else { failCount++; }
  } catch (e) {
    failCount++;
  }
}
Alert.alert('确认结果', `成功 ${successCount} 条,失败 ${failCount} 条`);
likedislike
hisakoTT
11 天前 评论:
240+ } else {
241+ Alert.alert('确认失败', '订单确认失败,请稍后重试。');
242+ }
243+ }
244+ } catch (e) {
245+ const errMsg = `finishTransaction 异常: ${String(e)}`;
246+ console.log(errMsg);
247+ Alert.alert('确认失败', '订单确认失败,请稍后重试。');
248+ }
249+ };
250+ 
251+ // 开始监听购买更新(鸿蒙端可在此注册购买结果回调)
252+ UpdatedListener = () => {
253+ try {
254+ this.purchaseUpdatedSubscription = purchaseUpdatedListener((purchase:Purchase)=>{
255+ console.log('购买成功事件:', purchase);
256+ // 使用格式化 JSON,确保所有字段都被记录
257+ const purchaseStr = JSON.stringify(purchase, null, 2);
258+ this.appendLog(`购买成功:\n${purchaseStr}`);
259+ });
260+ 
261+ this.setState({ UpdatedlisteningStatus: 'ok' });
262+ console.log('purchaseUpdatedListener 已调用,事件监听器已注册');
263+ } catch (e) {
264+ this.setState({ UpdatedlisteningStatus: 'fail' });
265+ console.log(`purchaseUpdatedListener 异常: ${String(e)}`);
266+ }
267+ };
268+ 
269+ // 开始监听购买更新(鸿蒙端可在此注册购买结果回调)
270+ ErrorListener = () => {
271+ try {
272+
273+ this.purchaseErrorSubscription = purchaseErrorListener((error:PurchaseError)=>{
274+ console.log('购买失败事件:', error);
275+ // 使用格式化 JSON,确保所有字段都被记录
276+ const errorStr = JSON.stringify(error, null, 2);
277+ this.appendLog(`购买失败:\n${errorStr}`);
278+ });
279+
280+ this.setState({ ErrorlisteningStatus: 'ok' });
281+ console.log('purchaseErrorListener 已调用,事件监听器已注册');
282+ } catch (e) {
283+ this.setState({ ErrorlisteningStatus: 'fail' });
284+ console.log(`purchaseErrorListener 异常: ${String(e)}`);
285+ }
286+ };
287+ 
288+ // 停止监听购买更新
289+ stopUpdatedListener = () => {
290+ try {
291+ if (this.purchaseUpdatedSubscription) {
292+ this.purchaseUpdatedSubscription.remove();
293+ this.purchaseUpdatedSubscription = null;
294+ }
295+ 
296+ this.setState({ UpdatedlisteningStatus: 'stopped' });
297+ console.log('stopListening 已调用,事件监听器已移除');
298+ } catch (e) {
299+ console.log(`stopListening 异常: ${String(e)}`);
300+ }
301+ };
302+ 
303+ // 停止监听购买更新
304+ stopErrorListener = () => {
305+ try {
306+ if (this.purchaseErrorSubscription) {
307+ this.purchaseErrorSubscription.remove();
308+ this.purchaseErrorSubscription = null;
309+ }
310+ this.setState({ ErrorlisteningStatus: 'stopped' });
311+ console.log('stopErrorListener 已调用,事件监听器已移除');
312+ } catch (e) {
313+ console.log(`stopErrorListener 异常: ${String(e)}`);
314+ }
315+ };
316+ 
317+ clearLog = () => this.setState({ log: '' });
318+ 
319+ // 获取购买历史记录
320+ fetchPurchaseHistory = async () => {
321+ try {
322+ const list = await getPurchaseHistory({
323+ alsoPublishToEventListener: false,
324+ automaticallyFinishRestoredTransactions: false,
325+ onlyIncludeActiveItems: false,
326+ });
327+ const arr = Array.isArray(list)
328+ ? (list as Record<string, unknown>[])
329+ : [];
330+ this.setState({ purchaseHistory: arr });
331+ console.log(`getPurchaseHistory: ${arr.length} 条`);
332+ Alert.alert('查询成功', `共 ${arr.length} 条购买记录`);
333+ } catch (e) {
334+ this.setState({ purchaseHistory: [] });
335+ console.log(`getPurchaseHistory 异常: ${String(e)}`);
336+ Alert.alert('查询失败', JSON.stringify(e));
337+ }
338+ };
339+ 
340+ // 获取应用交易信息
341+ fetchAppTransaction = async () => {
342+ try {
343+ const result = await getAppTransaction();
344+ this.setState({ appTransaction: result });
345+ console.log(`getAppTransaction: ${JSON.stringify(result)}`);
346+ if (result) {
347+ Alert.alert('查询成功', `应用交易信息: ${JSON.stringify(result, null, 2)}`);
348+ } else {
349+ Alert.alert('查询结果', '未找到应用交易信息');
350+ }
351+ } catch (e) {
352+ this.setState({ appTransaction: null });
353+ console.log(`getAppTransaction 异常: ${String(e)}`);
354+ Alert.alert('查询失败', JSON.stringify(e));
355+ }
356+ };
357+ 
358+ // 跳转订阅管理页面
359+ openSubscriptionManage = async () => {
360+ const sku = this.state.subscriptionSku;
361+ try {
362+ console.log(`deepLinkToSubscriptions: ${sku}`);
363+ await deepLinkToSubscriptions({sku});
364+ } catch (e) {
365+ console.log(`deepLinkToSubscriptions 异常: ${String(e)}`);
366+ Alert.alert('跳转失败', JSON.stringify(e));
367+ }
368+ };
369+ 
370+ componentWillUnmount() {
371+ if (this.purchaseUpdatedSubscription) {
372+ this.purchaseUpdatedSubscription.remove();
373+ this.purchaseUpdatedSubscription = null;
374+ }
375+ if (this.purchaseErrorSubscription) {
376+ this.purchaseErrorSubscription.remove();
377+ this.purchaseErrorSubscription = null;
378+ }
379+ }
380+ 
381+ render() {
382+ if (this.state.showTestPage) {
383+ return (
384+ <View style={styles.container}>
385+ <View style={styles.row}>
386+ <Button title="返回" onPress={() => this.setState({ showTestPage: false })} />
387+ </View>
388+ <TestPage />
389+ </View>
390+ );
391+ }
392+ return (
393+ <ScrollView>
394+ <Tester>
395+ <TestSuite name="IAP 示例">
396+ <Text style={styles.status}>
397+ 连接状态: {this.state.connected ? '已连接' : '未连接'}
398+ </Text>
399+ 
400+ <View>
401+ <View style={{ marginVertical: 6 }}>
402+ <TestCase itShould="initConnection">
403+ <Button title="初始化连接" onPress={this.initConnection} />
404+ </TestCase>
405+ </View>
406+ <View style={{ marginVertical: 6 }}>
407+ <TestCase itShould="endConnection">
408+ <Button title="结束初始化连接" onPress={this.endConnection} />
409+ </TestCase>
410+ </View>
411+ </View>
412+ <Text style={styles.productTypeHint}>
413+ inapp:消耗型 | subs:自动续期订阅
414+ </Text>
415+ <Text style={styles.label}>获取商品</Text>
416+ <CustomDropdown
417+ data={this.state.list}
418+ onSelect={item => {
419+ if (item == null) return;
420+ if (typeof item === 'string' || typeof item === 'number') {
421+ const typeStr = String(item);
422+ const found = this.state.list.find(
423+ i => String(i.type) === typeStr,
424+ );
425+ this.setState({
426+ productType: typeStr,
427+ skuIds: found ? String(found.id) : '',
428+ });
429+ } else if (typeof item === 'object') {
430+ const typeStr = String((item as any).type ?? '');
431+ const idStr = String((item as any).id ?? '');
432+ this.setState({ productType: typeStr, skuIds: idStr });
433+ }
434+ }}
435+ placeholder="请选择商品"
436+ />
437+ <View style={{ marginVertical: 6 }}>
438+ <TestCase itShould="getProducts & getSubscriptions">
439+ <Button
440+ title="按类型获取商品"
441+ onPress={this.getItemsByType}
442+ disabled={this.state.getItemsByTypeLoading}
443+ />
444+ </TestCase>
445+ </View>
446+ {this.state.productList.length > 0 ? (
447+ <View style={styles.productList}>
448+ <Text style={styles.productListTitle}>
449+ 当前商品列表
450+ </Text>
451+ {this.state.productList.map((item, i) => (
452+ <View key={i} style={styles.productItem}>
453+ <Text style={styles.productId}>
454+ {String(
455+ item.title ?? item.productName ?? item.productId ?? '-',
456+ )}
457+ </Text>
458+ <Text style={styles.productMeta}>
459+ ID: {String(item.productId ?? '-')}
460+ </Text>
461+ <View style={styles.productItemMetaRow}>
462+ <Text style={styles.productItemPrice}>
463+ 价格: {String(item.price ?? '-')}
464+ {item.currency ? ` ${item.currency}` : ''}
465+ </Text>
466+ {item.productType != null || item.type != null ? (
467+ <Text style={styles.productItemTypeBadge}>
468+ {String(
469+ item.productType ??
470+ item.type ??
471+ this.state.productType,
472+ )}
473+ </Text>
474+ ) : null}
475+ </View>
476+ {item.description != null &&
477+ String(item.description).length > 0 ? (
478+ <Text style={styles.productDesc} numberOfLines={2}>
479+ {String(item.description)}
480+ </Text>
481+ ) : null}
482+ <View style={{ marginVertical: 6}}>
483+ <TestCase itShould="requestPurchase & requestSubscription">
484+ <Button
485+ title="购买商品"
486+ onPress={() =>
487+ this.selectProductForPurchase(
488+ String(item.productId ?? ''),
489+ String(
490+ item.productType ??
491+ item.type ??
492+ this.state.productType,
493+ ),
494+ )
495+ }
496+ />
497+ </TestCase>
498+ </View>
499+ </View>
500+ ))}
501+ </View>
502+ ) : (
503+ <View style={styles.productListEmpty}>
504+ <Text style={styles.productListEmptyText}>暂无商品数据</Text>
505+ </View>
506+ )}
507+ 
508+ <Text style={styles.section}>购买 / 确认 / 消耗</Text>
509+ <View style={{ marginVertical: 6 }}>
510+ <TestCase itShould="finishTransaction">
511+ <Button
512+ title="确认完成所有订单"
513+ onPress={this.acknowledgePurchase}
514+ />
515+ </TestCase>
516+ </View>
517+ 
518+ <Text style={styles.section}>查询未完成订单</Text>
519+ <View style={{ marginVertical: 6 }}>
520+ <TestCase itShould="getAvailablePurchases">
521+ <Button
522+ title="查询已购买但未消耗订单"
523+ onPress={this.fetchAvailablePurchases}
524+ disabled={this.state.availablePurchasesLoading}
525+ />
526+ </TestCase>
527+ </View>
528+ {this.state.availablePurchasesLoading && (
529+ <Text style={styles.resultText}>查询中...</Text>
530+ )}
531+ {!this.state.availablePurchasesLoading &&
532+ this.state.availablePurchases.length > 0 && (
533+ <Text style={[styles.resultText, styles.resultSuccess]}>
534+ 共 {this.state.availablePurchases.length} 条未完成订单
535+ </Text>
536+ )}
537+ <View style={{ marginVertical: 6 }}>
538+ <TestCase itShould="getPurchaseHistory">
539+ <Button
540+ title="查询购买历史"
541+ onPress={this.fetchPurchaseHistory}
542+ />
543+ </TestCase>
544+ </View>
545+ {this.state.purchaseHistory.length > 0 && (
546+ <Text style={[styles.resultText, styles.resultSuccess]}>
547+ 共 {this.state.purchaseHistory.length} 条购买记录
548+ </Text>
549+ )}
550+ 
551+ <View style={{ marginVertical: 6 }}>
552+ <TestCase itShould="getAppTransaction">
553+ <Button
554+ title="获取应用交易信息"
555+ onPress={this.fetchAppTransaction}
556+ />
557+ </TestCase>
558+ </View>
559+ <View style={{ marginVertical: 6 }}>
560+ <TestCase itShould="deepLinkToSubscriptions">
561+ <Button
562+ title="打开订阅管理页面"
563+ onPress={this.openSubscriptionManage}
564+ />
565+ </TestCase>
566+ </View>
567+ <View style={{ marginVertical: 6 }}>
568+ <TestCase itShould="purchaseUpdatedListener">
569+ <Button
570+ title={
571+ this.state.UpdatedlisteningStatus === 'ok'
572+ ? '停止监听购买更新事件'
573+ : '开始监听购买更新事件'
574+ }
575+ onPress={
576+ this.state.UpdatedlisteningStatus === 'ok'
577+ ? this.stopUpdatedListener
578+ : this.UpdatedListener
579+ }
580+ />
581+ </TestCase>
582+ </View>
583+ <View style={{ marginTop: 6, marginBottom: 4 }}>
584+ {this.state.UpdatedlisteningStatus !== null ? (
585+ <Text
586+ style={[
587+ styles.resultText,
588+ this.state.UpdatedlisteningStatus === 'ok'
589+ ? styles.resultSuccess
590+ : this.state.UpdatedlisteningStatus === 'stopped'
591+ ? styles.resultText
592+ : styles.resultFail,
593+ ]}>
594+ 监听:{' '}
595+ {this.state.UpdatedlisteningStatus === 'ok'
596+ ? '已开始'
597+ : this.state.UpdatedlisteningStatus === 'stopped'
598+ ? '已停止'
599+ : '失败'}
600+ </Text>
601+ ) : null}
602+ </View>
603+ 
604+ 
605+ <View style={{ marginVertical: 6 }}>
606+ <TestCase itShould="purchaseErrorListener">
607+ <Button
608+ title={
609+ this.state.ErrorlisteningStatus === 'ok'
610+ ? '停止监听购买错误事件'
611+ : '开始监听购买错误事件'
612+ }
613+ onPress={
614+ this.state.ErrorlisteningStatus === 'ok'
615+ ? this.stopErrorListener
616+ : this.ErrorListener
617+ }
618+ />
619+ </TestCase>
620+ </View>
621+ <View style={{ marginTop: 6, marginBottom: 4 }}>
622+ {this.state.ErrorlisteningStatus !== null ? (
623+ <Text
624+ style={[
625+ styles.resultText,
626+ this.state.ErrorlisteningStatus === 'ok'
627+ ? styles.resultSuccess
628+ : this.state.ErrorlisteningStatus === 'stopped'
629+ ? styles.resultText
630+ : styles.resultFail,
631+ ]}>
632+ 监听:{' '}
633+ {this.state.ErrorlisteningStatus === 'ok'
634+ ? '已开始'
635+ : this.state.ErrorlisteningStatus === 'stopped'
636+ ? '已停止'
637+ : '失败'}
638+ </Text>
639+ ) : null}
640+ </View>
641+ 
642+ <View style={{ marginVertical: 6 }}>
643+ <TestCase itShould="clearLog">
644+ <Button title="清空监听日志" onPress={this.clearLog} />
645+ </TestCase>
646+ </View>
647+ <View style={{ marginVertical: 6 }}>
648+ {/* <TestCase itShould="useIAP"> */}
649+ <Button title="自定义Hook" onPress={
650+ ()=>{
651+ this.setState({ showTestPage: true })
652+ }
653+ } />
654+ </View>
655+ <Text style={styles.logLabel}>监听日志</Text>
656+ <Text style={styles.log} selectable>
657+ {this.state.log || '(无)'}
658+ </Text>
659+ </TestSuite>
660+ </Tester>
661+ </ScrollView>
662+ );
663+ }
664+}
665+ 
666+const styles = StyleSheet.create({
667+ container: {
668+ flex: 1,
669+ backgroundColor: '#f3f6f9',
670+ },
671+ content: {
672+ padding: 16,
673+ paddingBottom: 32,
674+ backgroundColor: '#ffffff',
675+ borderRadius: 8,
676+ margin: 12,
677+ },
678+ label: {
679+ fontSize: 24,
680+ marginBottom: 8,
681+ color: '#fcfbfbff',
682+ },
683+ status: {
684+ fontSize: 14,
685+ marginVertical: 4,
686+ color: '#f8f8f8ff',
687+ },
688+ flowHint: {
689+ fontSize: 12,
690+ marginVertical: 6,
691+ color: '#faf9f9ff',
692+ fontStyle: 'italic',
693+ },
694+ section: {
695+ fontSize: 16,
696+ fontWeight: '600',
697+ marginTop: 16,
698+ marginBottom: 8,
699+ color: '#FFFFFF',
700+ backgroundColor: '#333',
701+ paddingVertical: 6,
702+ paddingHorizontal: 10,
703+ borderRadius: 6,
704+ alignSelf: 'flex-start',
705+ },
706+ row: {
707+ flexDirection: 'row',
708+ flexWrap: 'wrap',
709+ gap: 8,
710+ marginVertical: 6,
711+ width: '100%',
712+ marginLeft: 15,
713+ marginTop: 38,
714+ },
715+ input: {
716+ borderWidth: 1,
717+ borderColor: '#ccc',
718+ backgroundColor: '#fff',
719+ color: '#0a0a0a',
720+ borderRadius: 6,
721+ padding: 10,
722+ marginVertical: 8,
723+ fontSize: 14,
724+ },
725+ logLabel: {
726+ fontSize: 16,
727+ color: '#FFF',
728+ fontWeight: '600',
729+ marginTop: 16,
730+ marginBottom: 6,
731+ },
732+ log: {
733+ fontSize: 12,
734+ color: '#555',
735+ backgroundColor: '#f5f5f5',
736+ padding: 10,
737+ borderRadius: 6,
738+ minHeight: 80,
739+ },
740+ hint: {
741+ fontSize: 11,
742+ color: '#FFFFFF',
743+ marginTop: 4,
744+ marginBottom: 8,
745+ },
746+ resultText: {
747+ fontSize: 14,
748+ marginTop: 6,
749+ marginBottom: 4,
750+ color: '#666',
751+ },
752+ resultSuccess: {
753+ color: '#2e7d32',
754+ fontWeight: '600',
755+ },
756+ resultFail: {
757+ color: '#c62828',
758+ fontWeight: '600',
759+ },
760+ productTypeHint: {
761+ fontSize: 12,
762+ color: '#faf9f9ff',
763+ marginBottom: 8,
764+ lineHeight: 18,
765+ },
766+ productList: {
767+ marginTop: 8,
768+ marginBottom: 8,
769+ },
770+ productListTitle: {
771+ fontSize: 16,
772+ fontWeight: '600',
773+ color: '#ffffff',
774+ marginTop: 0,
775+ marginBottom: 8,
776+ },
777+ productItem: {
778+ padding: 12,
779+ marginVertical: 6,
780+ backgroundColor: '#ffffff',
781+ borderRadius: 6,
782+ borderWidth: 1,
783+ borderColor: '#eee',
784+ shadowColor: '#000',
785+ shadowOpacity: 0.03,
786+ shadowRadius: 4,
787+ elevation: 1,
788+ },
789+ productId: {
790+ fontSize: 14,
791+ fontWeight: '600',
792+ color: '#111111',
793+ },
794+ productMeta: {
795+ fontSize: 12,
796+ color: '#555555',
797+ marginTop: 4,
798+ },
799+ productItemMetaRow: {
800+ flexDirection: 'row',
801+ alignItems: 'center',
802+ gap: 10,
803+ marginTop: 6,
804+ },
805+ productItemPrice: {
806+ fontSize: 14,
807+ fontWeight: '600',
808+ color: '#c62828',
809+ },
810+ productItemTypeBadge: {
811+ fontSize: 11,
812+ color: '#666',
813+ paddingHorizontal: 6,
814+ paddingVertical: 2,
815+ backgroundColor: '#eee',
816+ borderRadius: 4,
817+ },
818+ productDesc: {
819+ fontSize: 11,
820+ color: '#666666',
821+ marginTop: 6,
822+ fontStyle: 'italic',
823+ },
824+ productItemActions: {
825+ marginTop: 8,
826+ },
827+ productListEmpty: {
828+ padding: 16,
829+ marginTop: 8,
830+ marginBottom: 8,
831+ backgroundColor: '#ffffff',
832+ borderRadius: 8,
833+ borderWidth: 1,
834+ borderColor: '#eee',
835+ },
836+ productListEmptyText: {
837+ fontSize: 14,
838+ color: '#444444',
839+ marginBottom: 6,
840+ },
841+ productListEmptyHint: {
842+ fontSize: 12,
843+ color: '#000000ff',
844+ lineHeight: 18,
845+ },
846+});
847+ 
848+export const displayName = 'react-native-iap';
849+export const framework = 'React';
850+export const category = 'Base';
851+export const title = 'react-native-iap';
852+export const documentationURL = 'https://gitcode.com/CPF-RN/usage-docs/blob/br_rnoh0.61/zh-cn/react-native-iap.md';
853+export const description = 'react-native-iap';
854+export const examples = [
855+ {
856+ title: 'react-native-iap',
857+ render: function (): any {
858+ return <AppDemo />;
859+ },
860+ },
861+ ];
@@ -0,0 +1,247 @@
1+import React from 'react';
2+import { View, Text, Button, StyleSheet, Alert, ScrollView } from 'react-native';
3+import { requestPurchase, useIAP, withIAPContext } from 'react-native-iap';
4+ 
5+const App: JSX.Element = () => {
6+ const {
7+ connected,
8+ products,
9+ subscriptions,
10+ purchaseHistory,
11+ availablePurchases,
12+ currentPurchase,
13+ currentPurchaseError,
14+ initConnectionError,
15+ finishTransaction,
16+ getProducts,
17+ getSubscriptions,
18+ getAvailablePurchases,
19+ getPurchaseHistory,
20+ clearCurrentPurchase,
21+ clearCurrentPurchaseError,
22+ restorePurchases,
23+ requestSubscription,
24+ } = useIAP({
25+ onPurchaseSuccess: (purchase)=>{
26+ Alert.alert('PurchaseSuccess',JSON.stringify(purchase))
27+ },
28+ onPurchaseError: (PurchaseError)=>{
29+ Alert.alert('PurchaseError',JSON.stringify(PurchaseError))
30+ },
31+ });
32+ 
33+ const [textChoose, setTextChoose] = React.useState(-1)
34+ 
35+ const handlePurchase = async (sku: string) => {
36+ await requestPurchase({ sku });
37+ };
38+ 
39+ const handleSubscriptionPurchase = async (sku: string) => {
40+ await requestSubscription({ sku });
41+ };
42+ 
43+ const getSub = async () => {
44+ await getSubscriptions({ skus: ['SubProduct01001'] })
45+ }
46+ 
47+ const getAvailable = async () => {
48+ await getAvailablePurchases()
49+ }
50+ 
51+ const getHistory = async () => {
52+ await getPurchaseHistory()
53+ }
54+ 
55+ const finish = async (): Promise<void> => {
56+ for (let i = 0; i < availablePurchases.length; i++) {
57+ const purchase = availablePurchases[i]
58+ console.debug('iap = > ' + JSON.stringify(purchase.productTypeIos))
59+ const isConsumable = purchase.productTypeIos === 'inapp';
60+ const result = await finishTransaction({ purchase, isConsumable });
61+ console.debug('iap = > ' + JSON.stringify(result))
62+ if (result) {
63+ Alert.alert('确认成功', '订单已确认,商品权益已生效。');
64+ } else {
65+ Alert.alert('确认失败', '订单确认失败,请稍后重试。');
66+ }
67+ }
68+ }
69+ 
70+ return (
71+ <ScrollView>
72+ <View style={styles.container}>
73+ <View style={styles.state}>
74+ <ScrollView>
75+ <Text style={styles.textSize}>{textChoose === 0 && JSON.stringify(subscriptions) !== '[]' ? JSON.stringify(subscriptions) : ''}</Text>
76+ <Text style={styles.textSize}>{textChoose === 1 && JSON.stringify(availablePurchases) !== '[]' ? JSON.stringify(availablePurchases) : ''}</Text>
77+ <Text style={styles.textSize}>{textChoose === 2 && JSON.stringify(purchaseHistory) !== '[]' ? JSON.stringify(purchaseHistory) : ''}</Text>
78+ <Text style={styles.textSize}>{textChoose === 3 ? JSON.stringify(currentPurchase) : ''}</Text>
79+ <Text style={styles.textSize}>{textChoose === 4 ? JSON.stringify(currentPurchaseError) : ''}</Text>
80+ <Text style={styles.textSize}>{textChoose === 5 ? JSON.stringify(initConnectionError) : ''}</Text>
81+ <Text style={styles.textSize}>{textChoose === 6 && JSON.stringify(products) !== '[]' ? JSON.stringify(products) : ''}</Text>
82+ </ScrollView>
83+ </View>
84+ 
85+ <View style={styles.buttonBox}>
86+ <Button
87+ title="getSubscriptions"
88+ onPress={() => {
89+ getSub();
90+ setTextChoose(0);
91+ }}
92+ />
93+ </View>
94+ 
95+ <View style={styles.buttonBox}>
96+ <Button
97+ title="getAvailablePurchases"
98+ onPress={() => {
99+ getAvailable();
100+ setTextChoose(1);
101+ }}
102+ />
103+ </View>
104+ 
105+ <View style={styles.buttonBox}>
106+ <Button
107+ title="getPurchaseHistory"
108+ onPress={() => {
109+ getHistory();
110+ setTextChoose(2);
111+ }}
112+ />
113+ </View>
114+ 
115+ <View style={styles.buttonBox}>
116+ <Button
117+ title="finishTransaction"
118+ onPress={() => {
119+ finish();
120+ }}
121+ />
122+ </View>
123+ 
124+ <View style={styles.buttonBox}>
125+ <Button
126+ title="isConnected"
127+ onPress={() => {
128+ Alert.alert('connected', JSON.stringify(connected))
129+ }}
130+ />
131+ </View>
132+ 
133+ <View style={styles.buttonBox}>
134+ <Button
135+ title="currentPurchase"
136+ onPress={() => {
137+ setTextChoose(3);
138+ }}
139+ />
140+ </View>
141+ 
142+ <View style={styles.buttonBox}>
143+ <Button
144+ title="currentPurchaseError"
145+ onPress={() => {
146+ setTextChoose(4);
147+ }}
148+ />
149+ </View>
150+ 
151+ <View style={styles.buttonBox}>
152+ <Button
153+ title="initConnectionError"
154+ onPress={() => {
155+ setTextChoose(5);
156+ }}
157+ />
158+ </View>
159+ 
160+ 
161+ <View style={styles.buttonBox}>
162+ <Button
163+ title="clearCurrentPurchase"
164+ onPress={() => { clearCurrentPurchase() }}
165+ />
166+ </View>
167+ 
168+ <View style={styles.buttonBox}>
169+ <Button
170+ title="clearCurrentPurchaseError"
171+ onPress={() => { clearCurrentPurchaseError() }}
172+ />
173+ </View>
174+ 
175+ <View style={styles.buttonBox}>
176+ <Button
177+ title="restorePurchases"
178+ onPress={() => {
179+ restorePurchases()
180+ setTextChoose(1);
181+ }}
182+ />
183+ </View>
184+ 
185+ <View style={styles.buttonBox}>
186+ <Button
187+ title="getProducts"
188+ onPress={() => {
189+ getProducts({ skus: ['ohos_consume_002'] });
190+ setTextChoose(6);
191+ }}
192+ />
193+ </View>
194+ 
195+ {products.map((product) => (
196+ <View style={styles.product} key={product.productId}>
197+ <Button
198+ title="Buyproduct"
199+ onPress={() => handlePurchase(product.productId)}
200+ />
201+ </View>
202+ ))}
203+ 
204+ {subscriptions.map((subscription) => (
205+ <View style={styles.product} key={subscription.productId}>
206+ <Button
207+ title="Buysubscription"
208+ onPress={() => handleSubscriptionPurchase(subscription.productId)}
209+ />
210+ </View>
211+ ))}
212+ </View>
213+ </ScrollView>
214+ 
215+ );
216+};
217+ 
218+const styles = StyleSheet.create({
219+ container: {
220+ width: "100%",
221+ height: "100%",
222+ flexDirection: 'column',
223+ padding: 15
224+ },
225+ buttonBox: {
226+ marginTop: 30
227+ },
228+ state: {
229+ width: '100%',
230+ height: 300,
231+ backgroundColor: '#FFF',
232+ marginTop: 38,
233+ padding: 15,
234+ borderWidth: 1,
235+ borderRadius: 10,
236+ flexShrink: 1,
237+ numberOfLines: 8,
238+ },
239+ textSize: {
240+ fontSize: 20,
241+ },
242+ product: {
243+ marginTop: 30
244+ }
245+})
246+ 
247+export default withIAPContext(App)
A61tester/RNTester/examples/react-native-image-editor/ImageCropper.tsx+126-0文件内容审核中,请稍后刷新重试
@@ -0,0 +1,330 @@
1+import React, { Component } from 'react';
2+import { Image, ScrollView, Text, View, TextInput, StyleSheet, Button, Alert } from 'react-native';
3+ 
4+import { base64Data } from './utils'
5+import ImageEditor from '@react-native-community/image-editor';
6+ 
7+export interface Props {
8+ // noop
9+}
10+ 
11+interface Size {
12+ width: number;
13+ height: number;
14+}
15+ 
16+interface State {
17+ offsetX: number | string;
18+ offsetY: number | string;
19+ sizeWidth: number | string;
20+ sizeHeight: number | string;
21+ displaySizeWidth: number | string;
22+ displaySizeHeight: number | string;
23+ resizeMode: 'contain' | 'cover' | 'stretch'
24+ quality: number | string;
25+ format?: 'png' | 'jpeg' | 'webp';
26+ photoUri: any;
27+ photoWidth: number;
28+ photoHeight: number;
29+ croppedImageURI: string | null;
30+ targetSize?: Size;
31+ defaultType?: string;
32+ filePath?: string | null;
33+ horizontal: boolean;
34+ cropHorizontal: boolean;
35+ remoteWidth: number;
36+ remoteHeight: number;
37+}
38+ 
39+export class ImageCropperFull extends Component<Props, State> {
40+ constructor(props: Props) {
41+ super(props);
42+ this.state = {
43+ offsetX: '0',
44+ offsetY: '0',
45+ sizeWidth: '0',
46+ sizeHeight: '0',
47+ displaySizeWidth: '0',
48+ displaySizeHeight: '0',
49+ resizeMode: 'cover',
50+ quality: '0.9',
51+ format: 'jpeg',
52+ photoUri: base64Data,
53+ photoWidth: 1080,
54+ photoHeight: 720,
55+ croppedImageURI: null,
56+ targetSize: {
57+ width: 0, height: 0
58+ },
59+ defaultType: 'base',
60+ filePath: null,
61+ horizontal: true,
62+ cropHorizontal: false,
63+ remoteWidth: 0,
64+ remoteHeight: 0
65+ }
66+ this.remoteImage()
67+ }
68+ 
69+ remoteImage = () => {
70+ Image.getSize('https://octodex.github.com/images/OctoAsians_dex_Full.png', (width, height) => {
71+ this.setState({
72+ remoteWidth: width,
73+ remoteHeight: height
74+ })
75+ })
76+ }
77+ 
78+ _formChange = (value, key) => {
79+ this.setState({
80+ [key]: value
81+ })
82+ }
83+ 
84+ _changeType = (type: string): void => {
85+ let uri = base64Data
86+ let photoWidth: number = 1080
87+ let photoHeight: number = 720
88+ if (type === 'base') {
89+ uri = base64Data
90+ photoWidth = 1080
91+ photoHeight = 720
92+ }
93+ if (type === 'http') {
94+ uri = 'https://octodex.github.com/images/OctoAsians_dex_Full.png'
95+ photoWidth = this.state.remoteWidth || 896
96+ photoHeight = this.state.remoteHeight || 896
97+ }
98+ if (type === 'local') {
99+ uri = this.state.filePath
100+ photoWidth = this.state.targetSize?.width
101+ photoHeight = this.state.targetSize?.height
102+ }
103+ this.setState({
104+ defaultType: type,
105+ photoUri: uri,
106+ photoWidth,
107+ photoHeight
108+ })
109+ }
110+ 
111+ _crop = async () => {
112+ let imageCropData = {
113+ offset: { x: parseFloat(this.state.offsetX), y: parseFloat(this.state.offsetY) },
114+ size: { width: parseFloat(this.state.sizeWidth), height: parseFloat(this.state.sizeHeight) },
115+ displaySize: { width: parseFloat(this.state.displaySizeWidth), height: parseFloat(this.state.displaySizeHeight) },
116+ resizeMode: this.state.resizeMode,
117+ quality: parseFloat(this.state.quality),
118+ format: this.state.format,
119+ includeBase64: this.state.defaultType === 'base' ? true : false
120+ }
121+ if (imageCropData.size.width + imageCropData.offset.x > this.state.photoWidth || imageCropData.size.height + imageCropData.offset.y > this.state.photoHeight) {
122+ Alert.alert('The cropped size exceeds the original size')
123+ return
124+ }
125+ const cropResult = await ImageEditor.cropImage(
cpf-manager
cpf-managercpf-manager11 天前

【AI-Review】【一般】【基础代码问题】【稳定性问题】_crop 方法未捕获 ImageEditor.cropImage 异常

● 问题:ImageCropperFull 组件的 _crop 方法是 async 函数,调用了 await ImageEditor.cropImage(this.state.photoUri, imageCropData),但没有使用 try-catch 包裹。如果 ImageEditor.cropImage 抛出异常(例如用户输入非数字导致 parseFloat 返回 NaN、选择 'local' 类型但 filePath 为 null、原生模块处理失败等),异常会成为未处理的 Promise 拒绝。

对比同 PR 的 SquareImageCropper.tsx 第 158-175 行的 _crop 方法,该方法使用了 try-catch 捕获 cropError 并通过 this.setState({ cropError }) 更新状态,UI 通过 {cropError?.message} 显示错误信息。但 ImageCropperFull.tsx_crop 方法没有同样的错误处理。

触发路径:用户在 ImageCropperFull 界面输入无效的裁剪参数(如 offset/size 留空导致 parseFloat('') 返回 NaN)→ 边界检查 imageCropData.size.width + imageCropData.offset.x > this.state.photoWidthNaN > number 结果为 false,不会拦截 → 进入 ImageEditor.cropImage 调用 → 原生模块收到 NaN 参数抛出异常 → async 函数无 try-catch → 未处理的 Promise 拒绝。

● 影响:一般。当 ImageEditor.cropImage 失败时,由于没有 try-catch,用户看不到任何错误提示,且可能触发 React Native 的未处理 Promise 拒绝警告,影响调试和用户体验。

● 建议:使用 try-catch 包裹 ImageEditor.cropImage 调用,并在 catch 中通过 Alert 提示用户:

try {
  const cropResult = await ImageEditor.cropImage(this.state.photoUri, imageCropData);
  // ... 后续处理
} catch (e) {
  Alert.alert('裁剪失败', String(e));
}
likedislike
hisakoTT
11 天前 评论:
126+ this.state.photoUri,
127+ imageCropData
128+ );
129+ console.log('cropResult.uri', cropResult.uri)
130+ console.log('cropResult.path', cropResult.path)
131+ console.log('cropResult.name', cropResult.name)
132+ console.log('cropResult.width', cropResult.width)
133+ console.log('cropResult.height', cropResult.height)
134+ console.log('cropResult.size', cropResult.size)
135+ console.log('cropResult.type', cropResult.type)
136+ console.log('cropResult.base64', cropResult.base64)
137+ if (cropResult && cropResult.uri) {
138+ this.setState({
139+ croppedImageURI: cropResult.uri,
140+ filePath: cropResult.uri,
141+ targetSize: {
142+ width: imageCropData.size.width,
143+ height: imageCropData.size.height
144+ }
145+ });
146+ if (imageCropData.displaySize && imageCropData.displaySize.width && imageCropData.displaySize.height) {
147+ const aspect = imageCropData.size.width / imageCropData.size.height
148+ const targetAspect = imageCropData.displaySize.width / imageCropData.displaySize.height
149+ if (aspect === targetAspect || this.state.resizeMode === 'stretch' || this.state.resizeMode === 'cover') {
150+ this.setState({
151+ targetSize: {
152+ width: imageCropData.displaySize.width,
153+ height: imageCropData.displaySize.height
154+ }
155+ });
156+ }
157+ if (this.state.resizeMode === 'contain') {
158+ if (targetAspect <= aspect) {
159+ this.setState({
160+ targetSize: {
161+ width: imageCropData.displaySize.width,
162+ height: Math.ceil(imageCropData.displaySize.width / aspect)
163+ }
164+ });
165+ } else {
166+ this.setState({
167+ targetSize: {
168+ height: imageCropData.displaySize.height,
169+ width: Math.ceil(imageCropData.displaySize.height * aspect)
170+ }
171+ });
172+ }
173+ }
174+ }
175+ 
176+ if (this.state.targetSize.width >= this.state.targetSize.height) {
177+ this.setState({
178+ cropHorizontal: true
179+ })
180+ } else {
181+ this.setState({
182+ cropHorizontal: false
183+ })
184+ }
185+ 
186+ }
187+ }
188+ 
189+ render() {
190+ const { offsetX, offsetY, sizeWidth, sizeHeight, resizeMode, quality, format, photoUri, photoWidth, photoHeight, croppedImageURI, targetSize, defaultType, displaySizeWidth, displaySizeHeight, horizontal, cropHorizontal } = this.state
191+ return (
192+ <ScrollView>
193+ <Text>选择图片类型</Text>
194+ <View style={styles.flex}>
195+ <Button title="base64" onPress={() => this._changeType('base')} color={defaultType === 'base' ? 'green' : ''} />
196+ <Button title="远程图片" onPress={() => this._changeType('http')} color={defaultType === 'http' ? 'green' : ''} />
197+ <Button title="沙箱" onPress={() => this._changeType('local')} color={defaultType === 'local' ? 'green' : ''} />
198+ </View>
199+ 
200+ <ScrollView style={{ height: photoHeight }} horizontal={horizontal}>
201+ <Image source={{ uri: photoUri }} style={{ width: photoWidth, height: photoHeight }} />
202+ </ScrollView>
203+ {
204+ croppedImageURI ?
205+ <ScrollView style={{ height: targetSize?.height }} horizontal={cropHorizontal}>
206+ <Image source={{ uri: croppedImageURI }} style={{ width: targetSize?.width, height: targetSize?.height }} />
207+ </ScrollView> :
208+ <Text>未生成图片</Text>
209+ }
210+ 
211+ <View style={styles.flex}>
212+ <Text>offset:</Text>
213+ <TextInput
214+ style={styles.inputStyle}
215+ value={offsetX}
216+ inputMode="numeric"
217+ placeholder="x"
218+ onChangeText={(data) => {
219+ this._formChange(data, 'offsetX')
220+ }}
221+ />
222+ <TextInput
223+ style={styles.inputStyle}
224+ value={offsetY}
225+ inputMode="numeric"
226+ placeholder="y"
227+ onChangeText={(data) => {
228+ this._formChange(data, 'offsetY')
229+ }}
230+ />
231+ </View>
232+ 
233+ <View style={styles.flex}>
234+ <Text>size:</Text>
235+ <TextInput
236+ style={styles.inputStyle}
237+ value={sizeWidth}
238+ inputMode="numeric"
239+ placeholder="width"
240+ onChangeText={(data) => {
241+ this._formChange(data, 'sizeWidth')
242+ }}
243+ />
244+ <TextInput
245+ style={styles.inputStyle}
246+ value={sizeHeight}
247+ inputMode="numeric"
248+ placeholder="height"
249+ onChangeText={(data) => {
250+ this._formChange(data, 'sizeHeight')
251+ }}
252+ />
253+ </View>
254+ 
255+ <View style={styles.flex}>
256+ <Text>displaySize:</Text>
257+ <TextInput
258+ style={styles.inputStyle}
259+ value={displaySizeWidth}
260+ inputMode="numeric"
261+ placeholder="width"
262+ onChangeText={(data) => {
263+ this._formChange(data, 'displaySizeWidth')
264+ }}
265+ />
266+ <TextInput
267+ style={styles.inputStyle}
268+ value={displaySizeHeight}
269+ inputMode="numeric"
270+ placeholder="height"
271+ onChangeText={(data) => {
272+ this._formChange(data, 'displaySizeHeight')
273+ }}
274+ />
275+ </View>
276+ 
277+ <View style={styles.flex}>
278+ <Text>resizeMode:</Text>
279+ <Button title="contain" onPress={() => this._formChange('contain', 'resizeMode')} color={resizeMode === 'contain' ? 'green' : ''} />
280+ <Button title="cover" onPress={() => this._formChange('cover', 'resizeMode')} color={resizeMode === 'cover' ? 'green' : ''} />
281+ <Button title="stretch" onPress={() => this._formChange('stretch', 'resizeMode')} color={resizeMode === 'stretch' ? 'green' : ''} />
282+ {/* <Button title="center" onPress={()=>this._formChange('center', 'resizeMode')} color={resizeMode==='center' ? 'green' : ''} /> */}
283+ </View>
284+ 
285+ <View style={styles.flex}>
286+ <Text>quality:</Text>
287+ <Button title="0.3" onPress={() => this._formChange('0.3', 'quality')} color={quality === '0.3' ? 'green' : ''} />
288+ <Button title="0.5" onPress={() => this._formChange('0.5', 'quality')} color={quality === '0.5' ? 'green' : ''} />
289+ <Button title="0.9" onPress={() => this._formChange('0.9', 'quality')} color={quality === '0.9' ? 'green' : ''} />
290+ <Button title="1.0" onPress={() => this._formChange('1.0', 'quality')} color={quality === '1.0' ? 'green' : ''} />
291+ </View>
292+ 
293+ <View style={styles.flex}>
294+ <Text>format:</Text>
295+ <Button title="jpeg" onPress={() => this._formChange('jpeg', 'format')} color={format === 'jpeg' ? 'green' : ''} />
296+ <Button title="png" onPress={() => this._formChange('png', 'format')} color={format === 'png' ? 'green' : ''} />
297+ <Button title="webp" onPress={() => this._formChange('webp', 'format')} color={format === 'webp' ? 'green' : ''} />
298+ </View>
299+ 
300+ <View style={styles.button}>
301+ <Text>{croppedImageURI}</Text>
302+ <Button title="确定" onPress={() => this._crop()} />
303+ </View>
304+ </ScrollView>
305+ );
306+ }
307+}
308+ 
309+const styles = StyleSheet.create({
310+ button: {
311+ padding: 10
312+ },
313+ inputStyle: {
314+ width: 120,
315+ height: 35,
316+ padding: 5,
317+ borderRadius: 8,
318+ margin: 5,
319+ color: 'black',
320+ fontSize: 12,
321+ borderColor: 'black',
322+ borderWidth: 1
323+ },
324+ flex: {
325+ display: 'flex',
326+ flexDirection: 'row',
327+ justifyContent: 'flex-start',
328+ alignItems: 'center'
329+ }
330+})
@@ -0,0 +1,25 @@
1+import React from 'react';
2+ 
3+import { SquareImageCropper } from './SquareImageCropper';
4+ 
5+export default function ImageEditorDemo() {
6+ return <SquareImageCropper />;
7+}
8+ 
9+ 
10+// 使用 export 导出
11+export const displayName = "react-native-community_image-editor";
12+export const framework = "React";
13+export const category = "Basic";
14+export const title = "react-native-community_image-editor";
15+export const documentationURL = "https://gitcode.com/CPF-RN/usage-docs/blob/br_rnoh0.61/zh-cn/react-native-image-editor.md";
16+export const description = "Simple image-editor component.";
17+ 
18+export const examples = [
19+ {
20+ title: "image-editor with default styling",
21+ render: function (): any {
22+ return <ImageEditorDemo />;
23+ },
24+ },
25+];
@@ -0,0 +1,236 @@
1+import React, { Component } from 'react';
2+import {
3+ Image,
4+ StyleSheet,
5+ Text,
6+ TouchableHighlight,
7+ View,
8+ SafeAreaView,
9+ Button,
10+} from 'react-native';
11+import ImageEditor from '@react-native-community/image-editor';
12+ 
13+import type { LayoutChangeEvent } from 'react-native';
14+import { DEFAULT_IMAGE_WIDTH, DEFAULT_IMAGE_HEIGHT } from './constants';
15+import { ImageCropper } from './ImageCropper';
16+import { ImageCropperFull } from './ImageCropperFull';
17+import { base64Data } from './utils';
18+ 
19+import type { ImageCropData, ImageSize } from './types';
20+ 
21+interface State {
22+ croppedImageURI: string | null;
23+ cropError: Error | null;
24+ measuredSize: ImageSize | null;
25+}
26+interface Props {
27+ // noop
28+}
29+ 
30+export class SquareImageCropper extends Component<Props, State> {
31+ state: any;
32+ _isMounted: boolean;
33+ _transformData: ImageCropData | undefined;
34+ 
35+ constructor(props: Props) {
36+ super(props);
37+ this._isMounted = true;
38+ this.state = {
39+ photo: {
40+ uri: base64Data,
41+ height: DEFAULT_IMAGE_HEIGHT,
42+ width: DEFAULT_IMAGE_WIDTH,
43+ },
44+ measuredSize: null,
45+ croppedImageURI: null,
46+ cropError: null,
47+ demoType: 'demo1'
48+ };
49+ }
50+ 
51+ _onLayout = (event: LayoutChangeEvent) => {
52+ const measuredWidth = event.nativeEvent.layout.width;
53+ if (!measuredWidth) {
54+ return;
55+ }
56+ this.setState({
57+ measuredSize: { width: measuredWidth, height: measuredWidth },
58+ });
59+ };
60+ 
61+ _onTransformDataChange = (data: ImageCropData) => {
62+ this._transformData = data;
63+ };
64+ 
65+ demoChange = (demoType) => {
66+ this.setState({
67+ demoType
68+ })
69+ }
70+ 
71+ render() {
72+ if (!this.state.measuredSize) {
73+ return (
74+ <SafeAreaView style={styles.container} onLayout={this._onLayout} />
75+ );
76+ }
77+ 
78+ return (
79+ <SafeAreaView style={styles.containerPadding}>
80+ <View style={styles.contentContainer}>
81+ {
82+ this.state.demoType === 'demo1' ? !this.state.croppedImageURI ? this._renderImageCropper() : this._renderCroppedImage() : ''
83+ }
84+ {
85+ this.state.demoType === 'demo2' ? this._renderFullDemo() : ''
86+ }
87+ </View>
88+ <View style={styles.buttonRow}>
89+ <Button title="demo1" onPress={()=>this.demoChange('demo1')} />
90+ <Button title="demo2" onPress={()=>this.demoChange('demo2')} />
91+ </View>
92+ </SafeAreaView>
93+ )
94+ }
95+ 
96+ _renderImageCropper() {
97+ const { photo, cropError, measuredSize } = this.state;
98+ 
99+ if (!photo) {
100+ return <View style={styles.container} />;
101+ }
102+ 
103+ return (
104+ <View style={styles.container}>
105+ <Text style={styles.text} testID="headerText">
106+ Drag the image within the square to crop
107+ </Text>
108+ <ImageCropper
109+ image={photo}
110+ size={measuredSize}
111+ style={[styles.imageCropper, measuredSize]}
112+ onTransformDataChange={this._onTransformDataChange}
113+ />
114+ <TouchableHighlight
115+ accessibilityRole="button"
116+ style={styles.cropButtonTouchable}
117+ onPress={this._crop}
118+ >
119+ <View style={styles.cropButton}>
120+ <Text style={styles.cropButtonLabel}>Crop</Text>
121+ </View>
122+ </TouchableHighlight>
123+ <Text style={styles.errorText}>{cropError?.message}</Text>
124+ </View>
125+ );
126+ }
127+ 
128+ _renderCroppedImage() {
129+ return (
130+ <View style={styles.container}>
131+ <Text style={styles.text}>Here is the cropped image</Text>
132+ <Image
133+ accessibilityIgnoresInvertColors
134+ source={{ uri: this.state.croppedImageURI }}
135+ style={[styles.imageCropper, this.state.measuredSize]}
136+ />
137+ <TouchableHighlight
138+ accessibilityRole="button"
139+ style={styles.cropButtonTouchable}
140+ onPress={this._reset}
141+ >
142+ <View style={styles.cropButton}>
143+ <Text style={styles.cropButtonLabel}>Try again</Text>
144+ </View>
145+ </TouchableHighlight>
146+ <Text style={styles.errorText} />
147+ </View>
148+ );
149+ }
150+ 
151+ _renderFullDemo() {
152+ return (
153+ <View style={styles.container}>
154+ <ImageCropperFull />
155+ </View>
156+ )
157+ }
158+_crop = async () => {
159+ try {
160+ if (!this._transformData) {
161+ return;
162+ }
163+ const cropResult = await ImageEditor.cropImage(
164+ this.state.photo.uri,
165+ this._transformData
166+ );
167+ if (cropResult && cropResult.uri) {
168+ this.setState({ croppedImageURI: cropResult.uri });
169+ }
170+ } catch (cropError) {
171+ if (cropError instanceof Error) {
172+ this.setState({ cropError });
173+ }
174+ }
175+};
176+ 
177+ _reset = () => {
178+ this.setState({ croppedImageURI: null, cropError: null });
179+ };
180+}
181+ 
182+export default SquareImageCropper;
183+ 
184+const styles = StyleSheet.create({
185+ buttonRow: {
186+ flexDirection: 'row',
187+ justifyContent: 'space-around',
188+ padding: 16,
189+ },
190+ containerPadding: {
191+ flex: 1,
192+ justifyContent: 'space-between',
193+ paddingTop: 50,
194+ },
195+ contentContainer: {
196+ flex: 1,
197+ justifyContent: 'center',
198+ alignItems: 'center',
199+ },
200+ container: {
201+ flex: 1,
202+ backgroundColor: 'white',
203+ paddingTop: 0,
204+ alignItems: 'center',
205+ },
206+ imageCropper: {
207+ alignSelf: 'center',
208+ marginTop: 12,
209+ },
210+ cropButtonTouchable: {
211+ alignSelf: 'center',
212+ marginBottom: 10,
213+ marginTop: 'auto',
214+ backgroundColor: 'royalblue',
215+ borderRadius: 6,
216+ },
217+ cropButton: {
218+ padding: 12,
219+ },
220+ cropButtonLabel: {
221+ color: 'white',
222+ fontSize: 18,
223+ fontWeight: '500',
224+ },
225+ text: {
226+ color: 'black',
227+ textAlign: 'center',
228+ fontSize: 16,
229+ },
230+ errorText: {
231+ color: 'red',
232+ textAlign: 'center',
233+ fontSize: 16,
234+ marginBottom: 10,
235+ },
236+});
A61tester/RNTester/examples/react-native-image-editor/constants.ts+2-0文件内容审核中,请稍后刷新重试
A61tester/RNTester/examples/react-native-image-editor/types.ts+17-0文件内容审核中,请稍后刷新重试
A61tester/RNTester/examples/react-native-image-editor/utils.ts+1-0文件内容审核中,请稍后刷新重试
@@ -210,7 +210,15 @@ const ComponentExamples: Array<RNTesterExample> = [
210 {210 {
211 key: 'react-native-drag-sort',211 key: 'react-native-drag-sort',
212 module: require('../examples/react-native-drag-sort/index'),212 module: require('../examples/react-native-drag-sort/index'),
213- }, 213+ },
214+ {
215+ key: 'react-native-iap',
216+ module: require('../examples/react-native-iap/index'),
217+ },
218+ {
219+ key: 'react-native-image-editor',
220+ module: require('../examples/react-native-image-editor/ImageEditorDemo')
221+ },
214];222];
215 223 
216const APIExamples: Array<RNTesterExample> = [];224const APIExamples: Array<RNTesterExample> = [];
@@ -45,6 +45,8 @@
45 "@react-native-ohos/cookies": "file:../../node_modules/@react-native-ohos/cookies/harmony/rn_cookies.har",45 "@react-native-ohos/cookies": "file:../../node_modules/@react-native-ohos/cookies/harmony/rn_cookies.har",
46 "@react-native-ohos/react-native-restart": "file:../../node_modules/@react-native-ohos/react-native-restart/harmony/rn_restart.har",46 "@react-native-ohos/react-native-restart": "file:../../node_modules/@react-native-ohos/react-native-restart/harmony/rn_restart.har",
47 "@react-native-ohos/react-native-exit-app": "file:../../node_modules/@react-native-ohos/react-native-exit-app/harmony/exit_app.har",47 "@react-native-ohos/react-native-exit-app": "file:../../node_modules/@react-native-ohos/react-native-exit-app/harmony/exit_app.har",
48- "@react-native-ohos/react-native-image-resizer": "file:../../node_modules/@react-native-ohos/react-native-image-resizer/harmony/image_resizer.har"48+ "@react-native-ohos/react-native-image-resizer": "file:../../node_modules/@react-native-ohos/react-native-image-resizer/harmony/image_resizer.har",
49+ "@react-native-ohos/image-editor": "file:../../node_modules/@react-native-ohos/image-editor/harmony/image_editor.har",
50+ "@react-native-ohos/react-native-iap": "file:../../node_modules/@react-native-ohos/react-native-iap/harmony/react_native_iap.har"
49 }51 }
50}52}
@@ -55,6 +55,8 @@ add_subdirectory("${OH_MODULES}/@react-native-ohos/cookies/src/main/cpp" ./rn_co
55add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-restart/src/main/cpp" ./restart)55add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-restart/src/main/cpp" ./restart)
56add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-exit-app/src/main/cpp" ./exit_app)56add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-exit-app/src/main/cpp" ./exit_app)
57add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-image-resizer/src/main/cpp" ./image-resizer)57add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-image-resizer/src/main/cpp" ./image-resizer)
58+add_subdirectory("${OH_MODULES}/@react-native-ohos/react-native-iap/src/main/cpp" ./iap)
59+add_subdirectory("${OH_MODULES}/@react-native-ohos/image-editor/src/main/cpp" ./image-editor)
58file(GLOB GENERATED_CPP_FILES "${CMAKE_CURRENT_SOURCE_DIR}/generated/*.cpp")60file(GLOB GENERATED_CPP_FILES "${CMAKE_CURRENT_SOURCE_DIR}/generated/*.cpp")
59add_library(rnoh_app SHARED61add_library(rnoh_app SHARED
60 ${GENERATED_CPP_FILES}62 ${GENERATED_CPP_FILES}
@@ -104,4 +106,6 @@ target_link_libraries(rnoh_app PUBLIC rnoh_image_sequence_2)
104target_link_libraries(rnoh_app PUBLIC rnoh_cookies)106target_link_libraries(rnoh_app PUBLIC rnoh_cookies)
105target_link_libraries(rnoh_app PUBLIC rnoh_restart)107target_link_libraries(rnoh_app PUBLIC rnoh_restart)
106target_link_libraries(rnoh_app PUBLIC rnoh_exit_app)108target_link_libraries(rnoh_app PUBLIC rnoh_exit_app)
107-target_link_libraries(rnoh_app PUBLIC rnoh_ImageResizer)109+target_link_libraries(rnoh_app PUBLIC rnoh_ImageResizer)
110+target_link_libraries(rnoh_app PUBLIC rnoh_iap)
111+target_link_libraries(rnoh_app PUBLIC rnoh_image_editor)
@@ -48,6 +48,8 @@
48#include "RestartPackage.h"48#include "RestartPackage.h"
49#include "ExitAppPackage.h"49#include "ExitAppPackage.h"
50#include "ImageResizerPackage.h"50#include "ImageResizerPackage.h"
51+#include "ReactNativeOhosReactNativeImageEditorPackage.h"
52+#include "IapPackage.h"
51 53 
52using namespace rnoh;54using namespace rnoh;
53 55 
@@ -94,6 +96,8 @@ std::vector<std::shared_ptr<Package>> PackageProvider::getPackages(Package::Cont
94 std::make_shared<CookiesPackage>(ctx),96 std::make_shared<CookiesPackage>(ctx),
95 std::make_shared<RestartPackage>(ctx),97 std::make_shared<RestartPackage>(ctx),
96 std::make_shared<ExitAppPackage>(ctx),98 std::make_shared<ExitAppPackage>(ctx),
97- std::make_shared<ImageResizerPackage>(ctx)99+ std::make_shared<ImageResizerPackage>(ctx),
100+ std::make_shared<ReactNativeIapPackage>(ctx),
101+ std::make_shared<ImageEditorPackage>(ctx)
98 };102 };
99}103}
@@ -42,6 +42,8 @@ import { CookiesPackage } from '@react-native-ohos/cookies/ts';
42import { RNRestartPackage } from '@react-native-ohos/react-native-restart/ts';42import { RNRestartPackage } from '@react-native-ohos/react-native-restart/ts';
43import { ExitAppPackage } from '@react-native-ohos/react-native-exit-app/ts';43import { ExitAppPackage } from '@react-native-ohos/react-native-exit-app/ts';
44import { ImageResizerPackage } from '@react-native-ohos/react-native-image-resizer/ts';44import { ImageResizerPackage } from '@react-native-ohos/react-native-image-resizer/ts';
45+import { IapPackage } from '@react-native-ohos/react-native-iap/ts';
46+import { ImageEditorPackage } from '@react-native-ohos/image-editor/ts';
45 47 
46export function getRNOHPackages(ctx: RNPackageContext): RNPackage[] {48export function getRNOHPackages(ctx: RNPackageContext): RNPackage[] {
47 return [49 return [
@@ -79,6 +81,8 @@ export function getRNOHPackages(ctx: RNPackageContext): RNPackage[] {
79 new CookiesPackage(ctx),81 new CookiesPackage(ctx),
80 new RNRestartPackage(ctx),82 new RNRestartPackage(ctx),
81 new ExitAppPackage(ctx),83 new ExitAppPackage(ctx),
82- new ImageResizerPackage(ctx)84+ new ImageResizerPackage(ctx),
85+ new IapPackage(ctx),
86+ new ImageEditorPackage(ctx)
83 ]87 ]
84}88}
@@ -84,7 +84,9 @@
84 "@react-native-ohos/react-native-restart": "0.1.0",84 "@react-native-ohos/react-native-restart": "0.1.0",
85 "@react-native-ohos/react-native-exit-app": "2.1.0",85 "@react-native-ohos/react-native-exit-app": "2.1.0",
86 "@react-native-ohos/react-native-image-resizer": "3.1.0",86 "@react-native-ohos/react-native-image-resizer": "3.1.0",
87- "@react-native-ohos/react-native-drag-sort": "2.5.1-rc.1"87+ "@react-native-ohos/react-native-drag-sort": "2.5.1-rc.1",
88+ "@react-native-ohos/react-native-iap": "13.0.5",
89+ "@react-native-ohos/image-editor": "4.3.1"
88 },90 },
89 "devDependencies": {91 "devDependencies": {
90 "@babel/core": "^7.25.2",92 "@babel/core": "^7.25.2",