feat(sdk): 下载相册/进度、XWebView内联config、上报限频采样、版本忽略、push桥接原生sdk-push
- xwebview: 下载支持公共Downloads/相册(CameraRoll懒加载)/JS进度/openFile;XWebViewView 新增内联 config prop(同屏多实例不污染全局) - bugcollect: startCapture 自动启用 HttpInterceptor + 按指纹滑动窗口限频 + setSampleRate 采样(降低服务端存储压力) - update: ignoreAppVersion/clearIgnoredAppVersion/getIgnoredAppVersion 客户端忽略版本(强制更新除外) - common/metro: withXuqmConfig 自动链式 withBugCollect(Release 包 sourcemap 自动上传) - push: 桥接完整原生 sdk-push(Android Kotlin + iOS Swift + JS 薄层),删除旧反射实现;单一 XuqmSDK.setUserInfo 驱动厂商检测/token/绑定 Co-Authored-By: Claude <noreply@anthropic.com>
这个提交包含在:
父节点
1567c673ea
当前提交
4d2b834f96
@ -2,6 +2,7 @@ import { Platform, Dimensions } from 'react-native'
|
||||
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
|
||||
import { LogQueue } from './queue/LogQueue'
|
||||
import { ErrorCapture } from './capture/ErrorCapture'
|
||||
import { HttpInterceptor } from './interceptor/HttpInterceptor'
|
||||
import { computeFingerprint } from './fingerprint'
|
||||
import { FunnelTracker } from './funnel/FunnelTracker'
|
||||
import type {
|
||||
@ -25,8 +26,40 @@ let _logLevel: LogLevel = 'warn'
|
||||
let _environment: Environment = 'production'
|
||||
let _queue: LogQueue | null = null
|
||||
let _errorCaptureStarted = false
|
||||
let _httpInterceptorStarted = false
|
||||
let _breadcrumbs: BreadcrumbItem[] = []
|
||||
|
||||
// ─── 上报频率控制 / 采样(降低服务端存储与流量压力)───────────────────────────
|
||||
// 同一指纹在滑动窗口内最多上报 N 次,避免相同错误刷屏;fatal 不受限、不抽样。
|
||||
const RATE_WINDOW_MS = 60_000
|
||||
const RATE_MAX_PER_FINGERPRINT = 5
|
||||
let _sampleRate = 1 // 1=全量;0~1 对非致命事件抽样
|
||||
const _fingerprintHits = new Map<string, number[]>()
|
||||
|
||||
function _isFatal(event: BugCollectEvent): boolean {
|
||||
return event.type === 'issue' && event.level === 'fatal'
|
||||
}
|
||||
|
||||
function _eventKey(event: BugCollectEvent): string {
|
||||
return event.type === 'issue' ? event.fingerprint : `event:${event.name}`
|
||||
}
|
||||
|
||||
/** 是否允许上报:fatal 必报;否则先采样、再按指纹滑动窗口限频。 */
|
||||
function _shouldReport(event: BugCollectEvent): boolean {
|
||||
if (_isFatal(event)) return true
|
||||
if (_sampleRate < 1 && Math.random() > _sampleRate) return false
|
||||
const key = _eventKey(event)
|
||||
const now = Date.now()
|
||||
const hits = (_fingerprintHits.get(key) ?? []).filter(t => now - t < RATE_WINDOW_MS)
|
||||
if (hits.length >= RATE_MAX_PER_FINGERPRINT) {
|
||||
_fingerprintHits.set(key, hits)
|
||||
return false
|
||||
}
|
||||
hits.push(now)
|
||||
_fingerprintHits.set(key, hits)
|
||||
return true
|
||||
}
|
||||
|
||||
const _sessionId: string = _generateSessionId()
|
||||
|
||||
function _generateSessionId(): string {
|
||||
@ -76,6 +109,7 @@ function _enqueue(event: BugCollectEvent): void {
|
||||
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
|
||||
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
|
||||
}
|
||||
if (!_shouldReport(event)) return
|
||||
void _queue.push(event)
|
||||
}
|
||||
|
||||
@ -115,6 +149,11 @@ export const BugCollect = {
|
||||
_environment = env
|
||||
},
|
||||
|
||||
/** 设置非致命事件的采样率(0~1,默认 1=全量)。fatal 始终全量上报。 */
|
||||
setSampleRate(rate: number): void {
|
||||
_sampleRate = Math.max(0, Math.min(1, rate))
|
||||
},
|
||||
|
||||
/** Add a breadcrumb to the in-memory circular buffer (max 100). */
|
||||
addBreadcrumb(item: Omit<BreadcrumbItem, 'timestamp'> & { timestamp?: number }): void {
|
||||
const crumb: BreadcrumbItem = { ...item, timestamp: item.timestamp ?? Date.now() }
|
||||
@ -201,13 +240,19 @@ export const BugCollect = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable automatic capture of JS global errors and unhandled Promise rejections.
|
||||
* Enable automatic capture of JS global errors and unhandled Promise rejections,
|
||||
* plus HTTP/接口报错 自动上报(包裹 global.fetch)。
|
||||
* Call once at app startup after XuqmSDK.initialize().
|
||||
*/
|
||||
startCapture(): void {
|
||||
if (_errorCaptureStarted) return
|
||||
if (!_errorCaptureStarted) {
|
||||
_errorCaptureStarted = true
|
||||
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
if (!_httpInterceptorStarted) {
|
||||
_httpInterceptorStarted = true
|
||||
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
},
|
||||
|
||||
defineFunnel: FunnelTracker.define.bind(FunnelTracker),
|
||||
@ -222,6 +267,11 @@ export const BugCollect = {
|
||||
_queue.destroy()
|
||||
_queue = null
|
||||
}
|
||||
if (_httpInterceptorStarted) {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
_fingerprintHits.clear()
|
||||
_breadcrumbs = []
|
||||
},
|
||||
}
|
||||
|
||||
@ -46,13 +46,25 @@ function findConfigFile(projectRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyBugCollect(metroConfig) {
|
||||
// 可选链式:安装了 @xuqm/rn-bugcollect 时,自动启用 Release 包 SourceMap 上传。
|
||||
// 未安装则原样返回(不强制依赖)。
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const { withBugCollect } = require('@xuqm/rn-bugcollect/metro');
|
||||
return typeof withBugCollect === 'function' ? withBugCollect(metroConfig) : metroConfig;
|
||||
} catch {
|
||||
return metroConfig;
|
||||
}
|
||||
}
|
||||
|
||||
function withXuqmConfig(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot ?? process.cwd();
|
||||
const configFile = findConfigFile(projectRoot);
|
||||
|
||||
if (!configFile) {
|
||||
// 未找到配置文件,返回原配置(autoInit 会静默跳过)
|
||||
return metroConfig;
|
||||
return applyBugCollect(metroConfig);
|
||||
}
|
||||
|
||||
// 读取加密配置内容
|
||||
@ -71,7 +83,7 @@ function withXuqmConfig(metroConfig) {
|
||||
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
|
||||
const existingResolveRequest = metroConfig.resolver?.resolveRequest;
|
||||
|
||||
return {
|
||||
return applyBugCollect({
|
||||
...metroConfig,
|
||||
resolver: {
|
||||
...metroConfig.resolver,
|
||||
@ -90,7 +102,7 @@ function withXuqmConfig(metroConfig) {
|
||||
return context.resolveRequest(context, moduleName, platform);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { withXuqmConfig };
|
||||
|
||||
@ -33,6 +33,7 @@ export type {
|
||||
XWebViewConfig,
|
||||
XWebViewControllerAPI,
|
||||
XWebViewDownloadDecision,
|
||||
XWebViewDownloadDestination,
|
||||
XWebViewDownloadProgress,
|
||||
XWebViewDownloadRequest,
|
||||
XWebViewDownloadResult,
|
||||
|
||||
@ -54,6 +54,8 @@ export type XWebViewControllerAPI = {
|
||||
getTitle: () => string
|
||||
}
|
||||
|
||||
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
|
||||
|
||||
export type XWebViewConfig = {
|
||||
showTopBar?: boolean
|
||||
showStatusBar?: boolean
|
||||
@ -78,5 +80,9 @@ export type XWebViewConfig = {
|
||||
request: XWebViewDownloadRequest,
|
||||
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
|
||||
downloadConflict?: 'rename' | 'overwrite'
|
||||
/** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */
|
||||
downloadDestination?: XWebViewDownloadDestination
|
||||
/** 非空时显示状态栏下载进度通知。 */
|
||||
downloadNotificationTitle?: string
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven { url "https://nexus.xuqinmin.com/repository/android/" }
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.1.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.24")
|
||||
}
|
||||
}
|
||||
|
||||
apply plugin: "com.android.library"
|
||||
apply plugin: "org.jetbrains.kotlin.android"
|
||||
|
||||
android {
|
||||
compileSdkVersion 34
|
||||
namespace "com.xuqm.push"
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 34
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
maven { url "https://nexus.xuqinmin.com/repository/android/" }
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "com.facebook.react:react-android"
|
||||
// 桥接完整原生 sdk-push(带出 sdk-core)。版本须与 XuqmGroup-AndroidSDK/gradle.properties
|
||||
// 的 SDK_PUSH_VERSION / SDK_CORE_VERSION 保持一致;host app 通常已含,Gradle 去重。
|
||||
implementation "com.xuqm:sdk-push:1.1.3"
|
||||
implementation "com.xuqm:sdk-core:1.1.6-SNAPSHOT"
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
||||
@ -1,224 +0,0 @@
|
||||
package com.xuqm.push;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule;
|
||||
import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.Promise;
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule;
|
||||
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
public class XuqmPushModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private static final String TAG = "XuqmPushModule";
|
||||
private static final String EVENT_TOKEN = "XuqmPushToken";
|
||||
|
||||
public XuqmPushModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public String getName() {
|
||||
return "XuqmPushModule";
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void detectVendor(Promise promise) {
|
||||
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : "";
|
||||
String vendor;
|
||||
switch (manufacturer) {
|
||||
case "HUAWEI":
|
||||
vendor = "HUAWEI";
|
||||
break;
|
||||
case "XIAOMI":
|
||||
case "REDMI":
|
||||
vendor = "XIAOMI";
|
||||
break;
|
||||
case "HONOR":
|
||||
vendor = "HONOR";
|
||||
break;
|
||||
case "OPPO":
|
||||
case "REALME":
|
||||
vendor = "OPPO";
|
||||
break;
|
||||
case "VIVO":
|
||||
case "IQOO":
|
||||
vendor = "VIVO";
|
||||
break;
|
||||
default:
|
||||
vendor = "FCM";
|
||||
break;
|
||||
}
|
||||
promise.resolve(vendor);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void registerPush(Promise promise) {
|
||||
Context context = getReactApplicationContext();
|
||||
String vendor = resolveVendor();
|
||||
try {
|
||||
switch (vendor) {
|
||||
case "HUAWEI":
|
||||
registerHuawei(context);
|
||||
break;
|
||||
case "XIAOMI":
|
||||
registerXiaomi(context);
|
||||
break;
|
||||
case "OPPO":
|
||||
registerOppo(context);
|
||||
break;
|
||||
case "VIVO":
|
||||
registerVivo(context);
|
||||
break;
|
||||
case "HONOR":
|
||||
registerHonor(context);
|
||||
break;
|
||||
default:
|
||||
registerFcm(context);
|
||||
break;
|
||||
}
|
||||
promise.resolve(null);
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "registerPush failed: " + e.getMessage());
|
||||
promise.reject("PUSH_REGISTER_ERROR", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveVendor() {
|
||||
String manufacturer = Build.MANUFACTURER != null ? Build.MANUFACTURER.toUpperCase() : "";
|
||||
switch (manufacturer) {
|
||||
case "HUAWEI": return "HUAWEI";
|
||||
case "XIAOMI":
|
||||
case "REDMI": return "XIAOMI";
|
||||
case "HONOR": return "HONOR";
|
||||
case "OPPO":
|
||||
case "REALME": return "OPPO";
|
||||
case "VIVO":
|
||||
case "IQOO": return "VIVO";
|
||||
default: return "FCM";
|
||||
}
|
||||
}
|
||||
|
||||
private void emitToken(String token, String vendor) {
|
||||
com.facebook.react.bridge.WritableMap map = new com.facebook.react.bridge.WritableNativeMap();
|
||||
map.putString("token", token);
|
||||
map.putString("vendor", vendor);
|
||||
getReactApplicationContext()
|
||||
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class)
|
||||
.emit(EVENT_TOKEN, map);
|
||||
}
|
||||
|
||||
// ── Vendor registrations (via reflection) ────────────────────────────────
|
||||
|
||||
private void registerHuawei(Context context) throws Exception {
|
||||
Class<?> hmsClass = Class.forName("com.huawei.hms.aaid.HmsInstanceId");
|
||||
Object instance = hmsClass.getMethod("getInstance", Context.class).invoke(null, context);
|
||||
String appId = getHuaweiAppId(context);
|
||||
if (appId.isEmpty()) {
|
||||
Log.w(TAG, "Huawei appId not found");
|
||||
return;
|
||||
}
|
||||
Object token = hmsClass.getMethod("getToken", String.class, String.class)
|
||||
.invoke(instance, appId, "HCM");
|
||||
if (token instanceof String && !((String) token).isEmpty()) {
|
||||
emitToken((String) token, "HUAWEI");
|
||||
}
|
||||
}
|
||||
|
||||
private String getHuaweiAppId(Context context) {
|
||||
try {
|
||||
Class<?> configClass = Class.forName("com.huawei.agconnect.config.AGConnectServicesConfig");
|
||||
Object config = configClass.getMethod("fromContext", Context.class).invoke(null, context);
|
||||
return (String) configClass.getMethod("getString", String.class).invoke(config, "client/app_id");
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
private void registerXiaomi(Context context) throws Exception {
|
||||
Class<?> miPushClass = Class.forName("com.xiaomi.mipush.sdk.MiPushClient");
|
||||
Bundle meta = context.getPackageManager().getApplicationInfo(
|
||||
context.getPackageName(), PackageManager.GET_META_DATA).metaData;
|
||||
if (meta == null) return;
|
||||
String appId = meta.getString("XUQM_XIAOMI_APP_ID", "");
|
||||
String appKey = meta.getString("XUQM_XIAOMI_APP_KEY", "");
|
||||
if (!appId.isEmpty() && !appKey.isEmpty()) {
|
||||
miPushClass.getMethod("registerPush", Context.class, String.class, String.class)
|
||||
.invoke(null, context, appId, appKey);
|
||||
} else {
|
||||
Log.w(TAG, "Xiaomi appId/appKey not configured in meta-data");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerOppo(Context context) throws Exception {
|
||||
Class<?> pushManagerClass = Class.forName("com.heytap.mcssdk.PushManager");
|
||||
Object instance = pushManagerClass.getMethod("getInstance").invoke(null);
|
||||
Bundle meta = context.getPackageManager().getApplicationInfo(
|
||||
context.getPackageName(), PackageManager.GET_META_DATA).metaData;
|
||||
if (meta == null) return;
|
||||
String appKey = meta.getString("XUQM_OPPO_APP_KEY", "");
|
||||
String appSecret = meta.getString("XUQM_OPPO_APP_SECRET", "");
|
||||
if (appKey.isEmpty() || appSecret.isEmpty()) {
|
||||
Log.w(TAG, "OPPO appKey/appSecret not configured in meta-data");
|
||||
return;
|
||||
}
|
||||
Class<?> callbackClass = Class.forName("com.heytap.mcssdk.callback.PushCallback");
|
||||
Object proxy = Proxy.newProxyInstance(
|
||||
callbackClass.getClassLoader(),
|
||||
new Class<?>[]{callbackClass},
|
||||
(proxy1, method, args) -> {
|
||||
if ("onRegister".equals(method.getName()) && args != null && args.length > 1) {
|
||||
String regId = args[1] instanceof String ? (String) args[1] : null;
|
||||
if (regId != null && !regId.isEmpty()) {
|
||||
emitToken(regId, "OPPO");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
pushManagerClass.getMethod("register", Context.class, String.class, String.class, callbackClass)
|
||||
.invoke(instance, context, appKey, appSecret, proxy);
|
||||
}
|
||||
|
||||
private void registerVivo(Context context) throws Exception {
|
||||
Class<?> pushClientClass = Class.forName("com.vivo.push.PushClient");
|
||||
Object instance = pushClientClass.getMethod("getInstance", Context.class).invoke(null, context);
|
||||
pushClientClass.getMethod("initialize").invoke(instance);
|
||||
}
|
||||
|
||||
private void registerHonor(Context context) throws Exception {
|
||||
Class<?> honorPushClass = Class.forName("com.hihonor.push.sdk.HonorPushClient");
|
||||
Object client = honorPushClass.getMethod("getInstance").invoke(null);
|
||||
honorPushClass.getMethod("init", Context.class, boolean.class).invoke(client, context, true);
|
||||
}
|
||||
|
||||
private void registerFcm(Context context) throws Exception {
|
||||
Class<?> fcmClass = Class.forName("com.google.firebase.messaging.FirebaseMessaging");
|
||||
Object instance = fcmClass.getMethod("getInstance").invoke(null);
|
||||
Object task = fcmClass.getMethod("getToken").invoke(instance);
|
||||
Class<?> onSuccessClass = Class.forName("com.google.android.gms.tasks.OnSuccessListener");
|
||||
Object proxy = Proxy.newProxyInstance(
|
||||
onSuccessClass.getClassLoader(),
|
||||
new Class<?>[]{onSuccessClass},
|
||||
(proxy1, method, args) -> {
|
||||
if (args != null && args.length > 0 && args[0] instanceof String) {
|
||||
String token = (String) args[0];
|
||||
if (!token.isEmpty()) {
|
||||
emitToken(token, "FCM");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
);
|
||||
task.getClass().getMethod("addOnSuccessListener", onSuccessClass).invoke(task, proxy);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,83 @@
|
||||
package com.xuqm.push
|
||||
|
||||
import com.facebook.react.bridge.Promise
|
||||
import com.facebook.react.bridge.ReactApplicationContext
|
||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
||||
import com.facebook.react.bridge.ReactMethod
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
import com.xuqm.sdk.XuqmSDK
|
||||
import com.xuqm.sdk.XuqmUserInfo
|
||||
import com.xuqm.sdk.push.PushSDK
|
||||
|
||||
/**
|
||||
* push 桥接:薄封装完整原生 sdk-push。
|
||||
*
|
||||
* 设计要点(见 docs / [[rnsdk-prefer-pure-rn]]):
|
||||
* - SDK 初始化由 sdk-core 的 XuqmMergedProvider(ContentProvider) 在进程启动时自动完成(读 assets/config 下的 .xuqmconfig),
|
||||
* RN 侧无需调用 initialize。
|
||||
* - 用户态由唯一的 setUserInfo 驱动:JS XuqmSDK.setUserInfo() → 本模块 setUserInfo() → 原生 XuqmSDK.setUserInfo(),
|
||||
* 原生 notifyOptionalModules("onSdkLogin") 自动触发 PushSDK.onSdkLogin(),完成厂商检测/token/绑定。
|
||||
* - 不在 RN 侧做厂商检测/token/注册,避免与原生重复。
|
||||
*/
|
||||
class XuqmPushModule(reactContext: ReactApplicationContext) :
|
||||
ReactContextBaseJavaModule(reactContext) {
|
||||
|
||||
override fun getName(): String = NAME
|
||||
|
||||
companion object {
|
||||
const val NAME = "XuqmPushModule"
|
||||
}
|
||||
|
||||
/** 登录/登出统一入口:转发到原生 XuqmSDK.setUserInfo,原生自动扇出到 push 等模块。 */
|
||||
@ReactMethod
|
||||
fun setUserInfo(info: ReadableMap?, promise: Promise) {
|
||||
try {
|
||||
if (info == null || !info.hasKey("userId")) {
|
||||
XuqmSDK.setUserInfo(null)
|
||||
} else {
|
||||
XuqmSDK.setUserInfo(
|
||||
XuqmUserInfo(
|
||||
userId = info.getString("userId") ?: "",
|
||||
userSig = if (info.hasKey("userSig")) info.getString("userSig") else null,
|
||||
name = if (info.hasKey("name")) info.getString("name") else null,
|
||||
phone = if (info.hasKey("phone")) info.getString("phone") else null,
|
||||
),
|
||||
)
|
||||
}
|
||||
promise.resolve(null)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("SET_USER_INFO_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 离线推送开关(本地存储,随绑定同步)。 */
|
||||
@ReactMethod
|
||||
fun setOfflinePushEnabled(enabled: Boolean, promise: Promise) {
|
||||
try {
|
||||
PushSDK.setOfflinePushEnabled(enabled)
|
||||
promise.resolve(null)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("OFFLINE_PUSH_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun setQuietHours(start: String, end: String, promise: Promise) {
|
||||
try {
|
||||
PushSDK.setQuietHours(start, end)
|
||||
promise.resolve(null)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("QUIET_HOURS_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
fun clearQuietHours(promise: Promise) {
|
||||
try {
|
||||
PushSDK.clearQuietHours()
|
||||
promise.resolve(null)
|
||||
} catch (e: Throwable) {
|
||||
promise.reject("QUIET_HOURS_ERROR", e.message, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.xuqm.push;
|
||||
|
||||
import com.facebook.react.ReactPackage;
|
||||
import com.facebook.react.bridge.NativeModule;
|
||||
import com.facebook.react.bridge.ReactApplicationContext;
|
||||
import com.facebook.react.uimanager.ViewManager;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
public class XuqmPushPackage implements ReactPackage {
|
||||
|
||||
@Override
|
||||
public List<NativeModule> createNativeModules(ReactApplicationContext reactContext) {
|
||||
return Collections.<NativeModule>singletonList(new XuqmPushModule(reactContext));
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
@ -1,48 +1,10 @@
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
|
||||
@interface XuqmPushModule : RCTEventEmitter <RCTBridgeModule>
|
||||
@end
|
||||
// Swift 实现见 XuqmPushModule.swift(RCT_EXTERN_MODULE 桥接)。
|
||||
@interface RCT_EXTERN_MODULE(XuqmPushModule, NSObject)
|
||||
|
||||
@implementation XuqmPushModule
|
||||
|
||||
RCT_EXPORT_MODULE(XuqmPushModule);
|
||||
|
||||
- (NSArray<NSString *> *)supportedEvents {
|
||||
return @[@"XuqmPushToken"];
|
||||
}
|
||||
|
||||
+ (BOOL)requiresMainQueueSetup {
|
||||
return NO;
|
||||
}
|
||||
|
||||
RCT_EXPORT_METHOD(detectVendor:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject) {
|
||||
NSString *vendor = @"APNS";
|
||||
#if TARGET_OS_SIMULATOR
|
||||
vendor = @"FCM";
|
||||
#endif
|
||||
resolve(vendor);
|
||||
}
|
||||
|
||||
RCT_EXPORT_METHOD(registerPush:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[UIApplication sharedApplication] registerForRemoteNotifications];
|
||||
resolve(@(YES));
|
||||
});
|
||||
}
|
||||
|
||||
// Called by the AppDelegate to report the device token
|
||||
- (void)didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken {
|
||||
const unsigned char *dataBuffer = (const unsigned char *)[deviceToken bytes];
|
||||
NSUInteger dataLength = [deviceToken length];
|
||||
NSMutableString *hexString = [NSMutableString stringWithCapacity:(dataLength * 2)];
|
||||
for (NSUInteger i = 0; i < dataLength; ++i) {
|
||||
[hexString appendFormat:@"%02x", dataBuffer[i]];
|
||||
}
|
||||
NSString *token = [hexString copy];
|
||||
[self sendEventWithName:@"XuqmPushToken" body:@{@"token": token, @"vendor": @"APNS"}];
|
||||
}
|
||||
RCT_EXTERN_METHOD(setUserInfo:(NSDictionary *)info
|
||||
resolver:(RCTPromiseResolveBlock)resolve
|
||||
rejecter:(RCTPromiseRejectBlock)reject)
|
||||
|
||||
@end
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import Foundation
|
||||
import UIKit
|
||||
import React
|
||||
import XuqmCoreSDK
|
||||
|
||||
/**
|
||||
* push 桥接(iOS):薄封装原生 XuqmCoreSDK。
|
||||
*
|
||||
* 与 Android 一致的单一入口:JS XuqmSDK.setUserInfo() → 本模块 setUserInfo() →
|
||||
* XuqmSDK.shared.setUserInfo()/clearUserInfo()。APNs token 由 XuqmPushSDK 的
|
||||
* AppDelegate swizzle 自动转发到 XuqmSDK.registerDeviceToken,在 userId 就绪后完成绑定。
|
||||
*
|
||||
* SDK 初始化由 XuqmCoreSDK 自身的启动流程完成;offline/quiet-hours 在 iOS 原生 PushSDK
|
||||
* 暂无对应 API,故本模块不导出(JS 侧检测到方法缺失会自动 no-op)。
|
||||
*/
|
||||
@objc(XuqmPushModule)
|
||||
class XuqmPushModule: NSObject {
|
||||
|
||||
@objc static func requiresMainQueueSetup() -> Bool { false }
|
||||
|
||||
@objc(setUserInfo:resolver:rejecter:)
|
||||
func setUserInfo(_ info: NSDictionary?,
|
||||
resolver resolve: RCTPromiseResolveBlock,
|
||||
rejecter reject: RCTPromiseRejectBlock) {
|
||||
if let info = info, let userId = info["userId"] as? String, !userId.isEmpty {
|
||||
let nickname = info["name"] as? String
|
||||
XuqmSDK.shared.setUserInfo(userId: userId, nickname: nickname, avatar: nil)
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
} else {
|
||||
XuqmSDK.shared.clearUserInfo()
|
||||
}
|
||||
resolve(nil)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
dependency: {
|
||||
platforms: {
|
||||
android: {
|
||||
sourceDir: './android',
|
||||
packageImportPath: 'import com.xuqm.push.XuqmPushPackage;',
|
||||
packageInstance: 'new XuqmPushPackage()',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@ -1,33 +1,41 @@
|
||||
import { NativeModules, NativeEventEmitter, Platform } from 'react-native'
|
||||
import { NativeModules } from 'react-native'
|
||||
|
||||
interface XuqmPushModuleInterface {
|
||||
detectVendor: () => Promise<string>
|
||||
registerPush: () => Promise<boolean>
|
||||
import type { XuqmUserInfo } from '@xuqm/rn-common'
|
||||
|
||||
interface XuqmPushNative {
|
||||
/** 转发到原生 XuqmSDK.setUserInfo;原生自动完成厂商检测/token/绑定。传 null 登出。 */
|
||||
setUserInfo(info: Pick<XuqmUserInfo, 'userId' | 'userSig' | 'name' | 'phone'> | null): Promise<void>
|
||||
setOfflinePushEnabled(enabled: boolean): Promise<void>
|
||||
setQuietHours(start: string, end: string): Promise<void>
|
||||
clearQuietHours(): Promise<void>
|
||||
}
|
||||
|
||||
const _native = NativeModules.XuqmPushModule as XuqmPushModuleInterface | undefined
|
||||
const _eventEmitter = _native ? new NativeEventEmitter(_native as any) : null
|
||||
const _native = NativeModules.XuqmPushModule as XuqmPushNative | undefined
|
||||
|
||||
export function isNativePushAvailable(): boolean {
|
||||
return !!_native
|
||||
}
|
||||
|
||||
export async function detectVendorNative(): Promise<string> {
|
||||
if (!_native?.detectVendor) return Platform.OS === 'ios' ? 'APNS' : 'FCM'
|
||||
return _native.detectVendor()
|
||||
export async function nativeSetUserInfo(info: XuqmUserInfo | null): Promise<void> {
|
||||
if (!_native?.setUserInfo) return
|
||||
await _native.setUserInfo(
|
||||
info
|
||||
? { userId: info.userId, userSig: info.userSig, name: info.name, phone: info.phone }
|
||||
: null,
|
||||
)
|
||||
}
|
||||
|
||||
export async function registerPushNative(): Promise<void> {
|
||||
if (!_native?.registerPush) return
|
||||
await _native.registerPush()
|
||||
export async function nativeSetOfflinePushEnabled(enabled: boolean): Promise<void> {
|
||||
if (!_native?.setOfflinePushEnabled) return
|
||||
await _native.setOfflinePushEnabled(enabled)
|
||||
}
|
||||
|
||||
export function addPushTokenListener(
|
||||
callback: (event: { token: string; vendor: string }) => void,
|
||||
): () => void {
|
||||
if (!_eventEmitter) {
|
||||
return () => {}
|
||||
export async function nativeSetQuietHours(start: string, end: string): Promise<void> {
|
||||
if (!_native?.setQuietHours) return
|
||||
await _native.setQuietHours(start, end)
|
||||
}
|
||||
const subscription = _eventEmitter.addListener('XuqmPushToken', callback)
|
||||
return () => subscription.remove()
|
||||
|
||||
export async function nativeClearQuietHours(): Promise<void> {
|
||||
if (!_native?.clearQuietHours) return
|
||||
await _native.clearQuietHours()
|
||||
}
|
||||
|
||||
@ -1,139 +1,41 @@
|
||||
import {
|
||||
apiRequest,
|
||||
getConfig,
|
||||
getDeviceInfo,
|
||||
getUserId as getCommonUserId,
|
||||
_registerUserInfoHandler,
|
||||
} from '@xuqm/rn-common'
|
||||
import { _registerUserInfoHandler } from '@xuqm/rn-common'
|
||||
import type { PushVendor, XuqmUserInfo } from '@xuqm/rn-common'
|
||||
import { detectVendorNative, registerPushNative, addPushTokenListener } from './NativePush'
|
||||
import {
|
||||
nativeSetUserInfo,
|
||||
nativeSetOfflinePushEnabled,
|
||||
nativeSetQuietHours,
|
||||
nativeClearQuietHours,
|
||||
} from './NativePush'
|
||||
|
||||
export type { PushVendor }
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────────
|
||||
|
||||
let _registeredUserId: string | null = null
|
||||
let _tokenUnsubscribe: (() => void) | null = null
|
||||
|
||||
// ─── Internal helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
async function _registerDevice(userId: string, token: string, vendor: PushVendor): Promise<void> {
|
||||
const config = getConfig()
|
||||
const device = await getDeviceInfo()
|
||||
await apiRequest('/api/push/register', {
|
||||
method: 'POST',
|
||||
params: {
|
||||
appKey: config.appKey,
|
||||
userId,
|
||||
vendor,
|
||||
token,
|
||||
platform: device.platform,
|
||||
deviceId: device.deviceId,
|
||||
brand: device.brand,
|
||||
model: device.model,
|
||||
osVersion: device.osVersion,
|
||||
},
|
||||
})
|
||||
_registeredUserId = userId
|
||||
}
|
||||
|
||||
async function _unregisterDevice(userId: string): Promise<void> {
|
||||
const config = getConfig()
|
||||
const { deviceId } = await getDeviceInfo()
|
||||
await apiRequest('/api/push/unregister', {
|
||||
method: 'DELETE',
|
||||
params: { appKey: config.appKey, userId, deviceId },
|
||||
})
|
||||
if (_registeredUserId === userId) _registeredUserId = null
|
||||
}
|
||||
|
||||
async function _startRegistrationFlow(userId: string): Promise<void> {
|
||||
// 1. 检测厂商并请求原生注册
|
||||
await registerPushNative()
|
||||
|
||||
// 2. 监听 token 回调,收到后自动上报
|
||||
if (_tokenUnsubscribe) _tokenUnsubscribe()
|
||||
_tokenUnsubscribe = addPushTokenListener(async (event) => {
|
||||
const currentUserId = getCommonUserId()
|
||||
if (!currentUserId) return
|
||||
const vendor = (await detectVendorNative()) as PushVendor ?? (event.vendor as PushVendor)
|
||||
await _registerDevice(currentUserId, event.token, vendor).catch(() => {})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 订阅 XuqmSDK.setUserInfo ──────────────────────────────────────────────────
|
||||
|
||||
// 唯一入口:登录/登出都只转发到原生 XuqmSDK.setUserInfo,由原生 sdk-push 自动完成
|
||||
// 厂商检测 + token 获取 + 绑定到服务端(/api/push/register)。RN 侧不再做 vendor/token/register。
|
||||
_registerUserInfoHandler(async (info: XuqmUserInfo | null) => {
|
||||
if (!info) {
|
||||
// 登出:解绑 token
|
||||
const userId = _registeredUserId
|
||||
if (userId) {
|
||||
await _unregisterDevice(userId).catch(() => {})
|
||||
}
|
||||
if (_tokenUnsubscribe) {
|
||||
_tokenUnsubscribe()
|
||||
_tokenUnsubscribe = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 登录:启动设备注册流程(幂等,同一用户不重复注册)
|
||||
if (_registeredUserId === info.userId) return
|
||||
await _startRegistrationFlow(info.userId).catch(() => {})
|
||||
await nativeSetUserInfo(info).catch(() => {})
|
||||
})
|
||||
|
||||
// ─── PushSDK 公开 API ──────────────────────────────────────────────────────────
|
||||
|
||||
export const PushSDK = {
|
||||
/**
|
||||
* 设置离线推送开关。
|
||||
*/
|
||||
/** 设置离线推送开关(委托原生 PushSDK,随绑定同步到服务端)。 */
|
||||
async setOfflinePushEnabled(enabled: boolean): Promise<void> {
|
||||
const config = getConfig()
|
||||
const userId = getCommonUserId()
|
||||
if (!userId) return
|
||||
await apiRequest('/api/push/settings/offline', {
|
||||
method: 'PUT',
|
||||
params: { appKey: config.appKey, userId, enabled: String(enabled) },
|
||||
})
|
||||
await nativeSetOfflinePushEnabled(enabled)
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置免打扰时间段(24 小时制,如 '22:00'、'08:00')。
|
||||
*/
|
||||
/** 设置免打扰时间段(24 小时制,如 '22:00'、'08:00')。 */
|
||||
async setQuietHours(start: string, end: string): Promise<void> {
|
||||
const config = getConfig()
|
||||
const userId = getCommonUserId()
|
||||
if (!userId) return
|
||||
await apiRequest('/api/push/settings/quiet-hours', {
|
||||
method: 'PUT',
|
||||
params: { appKey: config.appKey, userId, start, end },
|
||||
})
|
||||
await nativeSetQuietHours(start, end)
|
||||
},
|
||||
|
||||
/**
|
||||
* 清除免打扰设置。
|
||||
*/
|
||||
/** 清除免打扰设置。 */
|
||||
async clearQuietHours(): Promise<void> {
|
||||
const config = getConfig()
|
||||
const userId = getCommonUserId()
|
||||
if (!userId) return
|
||||
await apiRequest('/api/push/settings/quiet-hours', {
|
||||
method: 'DELETE',
|
||||
params: { appKey: config.appKey, userId },
|
||||
})
|
||||
await nativeClearQuietHours()
|
||||
},
|
||||
|
||||
/**
|
||||
* 登出推送(通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。
|
||||
*/
|
||||
/** 登出推送(通常无需手动调用,XuqmSDK.setUserInfo(null) 会自动触发)。 */
|
||||
async logout(): Promise<void> {
|
||||
const userId = _registeredUserId ?? getCommonUserId()
|
||||
if (!userId) return
|
||||
await _unregisterDevice(userId).catch(() => {})
|
||||
if (_tokenUnsubscribe) {
|
||||
_tokenUnsubscribe()
|
||||
_tokenUnsubscribe = null
|
||||
}
|
||||
await nativeSetUserInfo(null)
|
||||
},
|
||||
}
|
||||
|
||||
@ -65,6 +65,15 @@ const _pluginRegistry = new Set<string>()
|
||||
|
||||
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
|
||||
const UPDATE_PLUGIN_CACHE_KEY_PREFIX = 'xuqm_update_plugin_cache_'
|
||||
// 用户「忽略此版本」持久化(按 versionCode)。强制更新不受忽略影响。
|
||||
const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code'
|
||||
|
||||
async function _getIgnoredVersionCode(): Promise<number | null> {
|
||||
const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null)
|
||||
if (!raw) return null
|
||||
const n = parseInt(raw, 10)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000 // 30 minutes
|
||||
|
||||
async function _readUpdateCache<T>(key: string): Promise<T | null> {
|
||||
@ -221,10 +230,19 @@ export const UpdateSDK = {
|
||||
// ── App 整包更新 ──────────────────────────────────────────────────────────
|
||||
|
||||
async checkAppUpdate(bypassIgnore?: boolean): Promise<AppUpdateInfo> {
|
||||
// 应用客户端忽略:非强制更新且 versionCode 已被忽略时视为无更新。
|
||||
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
|
||||
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
|
||||
const ignored = await _getIgnoredVersionCode()
|
||||
return ignored != null && info.versionCode === ignored
|
||||
? { ...info, needsUpdate: false }
|
||||
: info
|
||||
}
|
||||
|
||||
// Return cached result if within TTL (cache is skipped when bypassIgnore is set)
|
||||
if (!bypassIgnore) {
|
||||
const cached = await _readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
|
||||
if (cached) return cached
|
||||
if (cached) return applyIgnore(cached)
|
||||
}
|
||||
|
||||
const config = getConfig()
|
||||
@ -249,7 +267,23 @@ export const UpdateSDK = {
|
||||
})
|
||||
const normalized = { ...result, downloadUrl: normalizeDownloadUrl(result.downloadUrl) }
|
||||
await _writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
|
||||
return normalized
|
||||
return applyIgnore(normalized)
|
||||
},
|
||||
|
||||
/** 忽略指定版本(强制更新不受影响)。 */
|
||||
async ignoreAppVersion(versionCode: number): Promise<void> {
|
||||
if (!Number.isFinite(versionCode)) return
|
||||
await AsyncStorage.setItem(UPDATE_IGNORED_VERSION_KEY, String(versionCode))
|
||||
},
|
||||
|
||||
/** 清除已忽略的版本(恢复提示)。 */
|
||||
async clearIgnoredAppVersion(): Promise<void> {
|
||||
await AsyncStorage.removeItem(UPDATE_IGNORED_VERSION_KEY)
|
||||
},
|
||||
|
||||
/** 当前被忽略的 versionCode(无则 null)。 */
|
||||
async getIgnoredAppVersion(): Promise<number | null> {
|
||||
return _getIgnoredVersionCode()
|
||||
},
|
||||
|
||||
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
||||
|
||||
@ -28,6 +28,11 @@
|
||||
"react-native-safe-area-context": ">=4.0.0",
|
||||
"@xuqm/rn-common": ">=0.2.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-native-camera-roll/camera-roll": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@react-navigation/native": "^7.0.0",
|
||||
|
||||
@ -2,6 +2,7 @@ import { Platform } from 'react-native'
|
||||
|
||||
import type {
|
||||
XWebViewDownloadDecision,
|
||||
XWebViewDownloadDestination,
|
||||
XWebViewDownloadProgress,
|
||||
XWebViewDownloadRequest,
|
||||
XWebViewDownloadResult,
|
||||
@ -12,6 +13,63 @@ import type {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const getRNFetchBlob = () => (require('react-native-blob-util') as { default: typeof import('react-native-blob-util').default }).default
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
webp: 'image/webp',
|
||||
bmp: 'image/bmp',
|
||||
svg: 'image/svg+xml',
|
||||
doc: 'application/msword',
|
||||
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
xls: 'application/vnd.ms-excel',
|
||||
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
ppt: 'application/vnd.ms-powerpoint',
|
||||
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
zip: 'application/zip',
|
||||
rar: 'application/vnd.rar',
|
||||
txt: 'text/plain',
|
||||
csv: 'text/csv',
|
||||
json: 'application/json',
|
||||
mp4: 'video/mp4',
|
||||
mp3: 'audio/mpeg',
|
||||
}
|
||||
|
||||
function guessMime(filename: string): string {
|
||||
const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : ''
|
||||
return MIME_BY_EXT[ext] ?? 'application/octet-stream'
|
||||
}
|
||||
|
||||
function isImageMime(mime: string): boolean {
|
||||
return mime.startsWith('image/')
|
||||
}
|
||||
|
||||
// 懒加载 CameraRoll:可选依赖,缺失时静默跳过(不强制宿主安装)。
|
||||
function getCameraRoll(): { save: (uri: string, opts?: { type?: string; album?: string }) => Promise<string> } | null {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('@react-native-camera-roll/camera-roll')
|
||||
return mod?.CameraRoll ?? mod?.default?.CameraRoll ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 图片存入系统相册(best-effort,缺少 CameraRoll 或失败时返回 false)。 */
|
||||
async function saveToGallery(filePath: string): Promise<boolean> {
|
||||
const cameraRoll = getCameraRoll()
|
||||
if (!cameraRoll) return false
|
||||
try {
|
||||
const uri = filePath.startsWith('file://') ? filePath : `file://${filePath}`
|
||||
await cameraRoll.save(uri, { type: 'photo' })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(header: string): string | null {
|
||||
const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i)
|
||||
if (rfc5987?.[1]) {
|
||||
@ -91,20 +149,66 @@ async function resolveFilePath(
|
||||
return candidate
|
||||
}
|
||||
|
||||
/**
|
||||
* 用系统查看器打开已下载文件(Android: actionViewIntent;iOS: openDocument)。
|
||||
* best-effort,失败不抛出。
|
||||
*/
|
||||
export async function openFile(filePath: string, filenameOrMime?: string): Promise<boolean> {
|
||||
const mime =
|
||||
filenameOrMime && filenameOrMime.includes('/')
|
||||
? filenameOrMime
|
||||
: guessMime(filenameOrMime ?? filePath)
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
const res = await getRNFetchBlob().android.actionViewIntent(filePath, mime)
|
||||
return res !== false
|
||||
}
|
||||
await getRNFetchBlob().ios.openDocument(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBase64File(
|
||||
base64: string,
|
||||
filename: string,
|
||||
savePath: string | undefined,
|
||||
conflict: 'rename' | 'overwrite',
|
||||
destination: XWebViewDownloadDestination = 'sandbox',
|
||||
): Promise<string> {
|
||||
const { dirs } = getRNFetchBlob().fs
|
||||
const blob = getRNFetchBlob()
|
||||
const { dirs } = blob.fs
|
||||
|
||||
// 公共 Downloads(Android):先写沙盒,再通过 DownloadManager 登记到“下载”列表。
|
||||
// Android 10+ 直接写公共目录会 EACCES,故走沙盒 + addCompleteDownload。
|
||||
// 指定了 savePath 时尊重调用方目录,不走此分支。
|
||||
if (Platform.OS === 'android' && destination === 'publicDownloads' && !savePath) {
|
||||
const sandboxPath = await resolveFilePath(dirs.DownloadDir ?? dirs.DocumentDir, filename, conflict)
|
||||
await blob.fs.writeFile(sandboxPath, base64, 'base64')
|
||||
const mime = guessMime(filename)
|
||||
try {
|
||||
await blob.android.addCompleteDownload({
|
||||
title: filename,
|
||||
description: filename,
|
||||
mime,
|
||||
path: sandboxPath,
|
||||
showNotification: true,
|
||||
})
|
||||
} catch {
|
||||
// 登记失败不影响文件已落盘
|
||||
}
|
||||
if (isImageMime(mime)) await saveToGallery(sandboxPath)
|
||||
return sandboxPath
|
||||
}
|
||||
|
||||
const dir =
|
||||
savePath ??
|
||||
(Platform.OS === 'android'
|
||||
? (dirs.DownloadDir ?? dirs.DocumentDir)
|
||||
: dirs.DocumentDir)
|
||||
const filePath = await resolveFilePath(dir, filename, conflict)
|
||||
await getRNFetchBlob().fs.writeFile(filePath, base64, 'base64')
|
||||
await blob.fs.writeFile(filePath, base64, 'base64')
|
||||
return filePath
|
||||
}
|
||||
|
||||
@ -125,6 +229,8 @@ export async function handleDownloadRequest(
|
||||
autoDownload?: boolean
|
||||
downloadConflict?: 'rename' | 'overwrite'
|
||||
savePath?: string
|
||||
downloadDestination?: XWebViewDownloadDestination
|
||||
downloadNotificationTitle?: string
|
||||
onDownloadDecide?: (
|
||||
request: XWebViewDownloadRequest,
|
||||
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
|
||||
@ -138,6 +244,8 @@ export async function handleDownloadRequest(
|
||||
autoDownload = true,
|
||||
downloadConflict = 'rename',
|
||||
savePath,
|
||||
downloadDestination = 'sandbox',
|
||||
downloadNotificationTitle,
|
||||
onDownloadDecide,
|
||||
onDownloadStart,
|
||||
onDownloadProgress,
|
||||
@ -168,6 +276,7 @@ export async function handleDownloadRequest(
|
||||
p => onDownloadProgress?.(p),
|
||||
r => onDownloadComplete?.(r),
|
||||
err => onDownloadError?.(url, err),
|
||||
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
|
||||
)
|
||||
}
|
||||
|
||||
@ -179,8 +288,67 @@ export function startDownload(
|
||||
onProgress: (p: XWebViewDownloadProgress) => void,
|
||||
onComplete: (r: XWebViewDownloadResult) => void,
|
||||
onError: (error: string) => void,
|
||||
options: {
|
||||
destination?: XWebViewDownloadDestination
|
||||
notificationTitle?: string
|
||||
} = {},
|
||||
): DownloadHandle {
|
||||
const { dirs } = getRNFetchBlob().fs
|
||||
const { destination = 'sandbox', notificationTitle } = options
|
||||
const blob = getRNFetchBlob()
|
||||
const { dirs } = blob.fs
|
||||
|
||||
// 公共 Downloads(Android):下载到沙盒(保留 JS 进度回调),完成后通过 DownloadManager
|
||||
// 登记到“下载”列表(完成通知)、图片另存系统相册。Android 10+ 不能直接写公共目录,故沙盒落地 + 登记。
|
||||
// 指定了 savePath 时尊重调用方目录,不走此分支。
|
||||
if (Platform.OS === 'android' && destination === 'publicDownloads' && !savePath) {
|
||||
let cancelled = false
|
||||
let cancelFn = () => {
|
||||
cancelled = true
|
||||
}
|
||||
const mime = guessMime(filename)
|
||||
resolveFilePath(dirs.DocumentDir, filename, conflict)
|
||||
.then(cachePath => {
|
||||
if (cancelled) return
|
||||
const task = blob.config({ path: cachePath }).fetch('GET', url)
|
||||
cancelFn = () => task.cancel()
|
||||
task.progress({ interval: 300 }, (received: number, total: number) => {
|
||||
const recv = Number(received)
|
||||
const tot = Number(total)
|
||||
onProgress({
|
||||
url,
|
||||
filename,
|
||||
received: recv,
|
||||
total: tot,
|
||||
percentage: tot > 0 ? Math.round((recv / tot) * 100) : -1,
|
||||
})
|
||||
})
|
||||
task
|
||||
.then(async (res: { path: () => string }) => {
|
||||
const filePath = res.path()
|
||||
try {
|
||||
await blob.android.addCompleteDownload({
|
||||
title: notificationTitle?.trim() || filename,
|
||||
description: filename,
|
||||
mime,
|
||||
path: filePath,
|
||||
showNotification: true,
|
||||
})
|
||||
} catch {
|
||||
// 登记失败不影响文件已落盘
|
||||
}
|
||||
if (isImageMime(mime)) await saveToGallery(filePath)
|
||||
if (!cancelled) onComplete({ url, filename, filePath, fileSize: 0 })
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
if (!cancelled && !String(err?.message ?? '').toLowerCase().includes('cancel')) {
|
||||
onError(err?.message ?? String(err))
|
||||
}
|
||||
})
|
||||
})
|
||||
.catch(err => onError(String(err)))
|
||||
return { cancel: () => cancelFn() }
|
||||
}
|
||||
|
||||
const dir =
|
||||
savePath ??
|
||||
(Platform.OS === 'android'
|
||||
|
||||
@ -261,6 +261,8 @@ export function XWebViewScreen() {
|
||||
onPermissionRequest,
|
||||
autoDownload = true,
|
||||
downloadConflict = 'rename',
|
||||
downloadDestination = 'sandbox',
|
||||
downloadNotificationTitle,
|
||||
onDownloadStart,
|
||||
onDownloadProgress,
|
||||
onDownloadComplete,
|
||||
@ -358,13 +360,19 @@ export function XWebViewScreen() {
|
||||
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
|
||||
|
||||
try {
|
||||
const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict)
|
||||
const filePath = await saveBase64File(
|
||||
base64,
|
||||
finalFilename,
|
||||
finalSavePath,
|
||||
downloadConflict,
|
||||
downloadDestination,
|
||||
)
|
||||
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
|
||||
} catch (err) {
|
||||
onDownloadError?.(blobUrl, String(err))
|
||||
}
|
||||
},
|
||||
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
|
||||
[autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
|
||||
)
|
||||
|
||||
const processDownload = useCallback(
|
||||
@ -392,9 +400,10 @@ export function XWebViewScreen() {
|
||||
p => onDownloadProgress?.(p),
|
||||
r => onDownloadComplete?.(r),
|
||||
err => onDownloadError?.(downloadUrl, err),
|
||||
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
|
||||
)
|
||||
},
|
||||
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
|
||||
[autoDownload, downloadConflict, downloadDestination, downloadNotificationTitle, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
|
||||
@ -135,10 +135,13 @@ const DIALOG_OVERRIDE_JS = `
|
||||
true;
|
||||
`
|
||||
|
||||
export function XWebViewView() {
|
||||
export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } = {}) {
|
||||
const webViewRef = useRef<WebView>(null)
|
||||
|
||||
const config = getXWebViewConfig()
|
||||
// configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例);
|
||||
// 不传时回退到 openXWebView 设置的全局 config(全屏/miniapp 用法)。
|
||||
const inline = configProp != null
|
||||
const config = configProp ?? getXWebViewConfig()
|
||||
const {
|
||||
showStatusBar = true,
|
||||
title: initialTitle = '',
|
||||
@ -149,6 +152,8 @@ export function XWebViewView() {
|
||||
onPermissionRequest,
|
||||
autoDownload = true,
|
||||
downloadConflict = 'rename',
|
||||
downloadDestination = 'sandbox',
|
||||
downloadNotificationTitle,
|
||||
onDownloadStart,
|
||||
onDownloadProgress,
|
||||
onDownloadComplete,
|
||||
@ -165,6 +170,8 @@ export function XWebViewView() {
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
// 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。
|
||||
if (inline) return
|
||||
setXWebViewController({
|
||||
refresh: () => webViewRef.current?.reload(),
|
||||
close: () => onClose?.(),
|
||||
@ -175,7 +182,7 @@ export function XWebViewView() {
|
||||
getTitle: () => currentTitle,
|
||||
})
|
||||
return () => setXWebViewController(null)
|
||||
}, [currentUrl, currentTitle, onClose])
|
||||
}, [inline, currentUrl, currentTitle, onClose])
|
||||
|
||||
const handleNavigationStateChange = useCallback((state: WebViewNavigation) => {
|
||||
setCanGoBack(state.canGoBack)
|
||||
@ -205,13 +212,19 @@ export function XWebViewView() {
|
||||
|
||||
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
|
||||
try {
|
||||
const filePath = await saveBase64File(base64, finalFilename, finalSavePath, downloadConflict)
|
||||
const filePath = await saveBase64File(
|
||||
base64,
|
||||
finalFilename,
|
||||
finalSavePath,
|
||||
downloadConflict,
|
||||
downloadDestination,
|
||||
)
|
||||
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
|
||||
} catch (err) {
|
||||
onDownloadError?.(blobUrl, String(err))
|
||||
}
|
||||
},
|
||||
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
|
||||
[autoDownload, downloadConflict, downloadDestination, onDownloadDecide, onDownloadStart, onDownloadComplete, onDownloadError],
|
||||
)
|
||||
|
||||
const processDownload = useCallback(
|
||||
@ -237,9 +250,10 @@ export function XWebViewView() {
|
||||
p => onDownloadProgress?.(p),
|
||||
r => onDownloadComplete?.(r),
|
||||
err => onDownloadError?.(downloadUrl, err),
|
||||
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
|
||||
)
|
||||
},
|
||||
[autoDownload, downloadConflict, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
|
||||
[autoDownload, downloadConflict, downloadDestination, downloadNotificationTitle, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError],
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
|
||||
@ -11,6 +11,7 @@ export type {
|
||||
XWebViewConfig,
|
||||
XWebViewControllerAPI,
|
||||
XWebViewDownloadDecision,
|
||||
XWebViewDownloadDestination,
|
||||
XWebViewDownloadProgress,
|
||||
XWebViewDownloadRequest,
|
||||
XWebViewDownloadResult,
|
||||
@ -21,5 +22,5 @@ export type {
|
||||
export { default as XWebViewProgress } from './XWebViewProgress'
|
||||
export { XWebViewView } from './XWebViewView'
|
||||
export { XWebViewScreen } from './XWebViewScreen'
|
||||
export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload'
|
||||
export { handleDownloadRequest, fetchDownloadInfo, saveBase64File, startDownload, openFile } from './XWebViewDownload'
|
||||
export type { DownloadHandle } from './XWebViewDownload'
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户