diff --git a/.gitignore b/.gitignore index 27771124..8f5b1635 100644 --- a/.gitignore +++ b/.gitignore @@ -78,3 +78,7 @@ test-report.html # Ignore tamagui config file .tamagui scripts/nitro-view/template/android/.gradle + +# generated by react-native-builder-bob +native-modules/*/lib/ +native-views/*/lib/ diff --git a/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch b/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch new file mode 100644 index 00000000..bb42fc62 --- /dev/null +++ b/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch @@ -0,0 +1,34 @@ +diff --git a/third-party-podspecs/RCT-Folly.podspec b/third-party-podspecs/RCT-Folly.podspec +index 8852179b6ce2ef6ae64e0239edb2594925ff8bc1..040c4f0ca82e6a7342d5b3ed54e556431e5853d2 100644 +--- a/third-party-podspecs/RCT-Folly.podspec ++++ b/third-party-podspecs/RCT-Folly.podspec +@@ -25,7 +25,7 @@ Pod::Spec.new do |spec| + spec.dependency "DoubleConversion" + spec.dependency "glog" + spec.dependency "fast_float", "8.0.0" +- spec.dependency "fmt", "11.0.2" ++ spec.dependency "fmt", "12.1.0" + spec.compiler_flags = '-Wno-documentation -faligned-new' + spec.source_files = 'folly/String.cpp', + 'folly/Conv.cpp', +diff --git a/third-party-podspecs/fmt.podspec b/third-party-podspecs/fmt.podspec +index 2f38990e226c13f483aaf1b986302d4094243814..a40c5755e049974e98a4038c177661d2bdc5681f 100644 +--- a/third-party-podspecs/fmt.podspec ++++ b/third-party-podspecs/fmt.podspec +@@ -8,14 +8,14 @@ fmt_git_url = fmt_config[:git] + + Pod::Spec.new do |spec| + spec.name = "fmt" +- spec.version = "11.0.2" ++ spec.version = "12.1.0" + spec.license = { :type => "MIT" } + spec.homepage = "https://github.com/fmtlib/fmt" + spec.summary = "{fmt} is an open-source formatting library for C++. It can be used as a safe and fast alternative to (s)printf and iostreams." + spec.authors = "The fmt contributors" + spec.source = { + :git => fmt_git_url, +- :tag => "11.0.2" ++ :tag => "12.1.0" + } + spec.pod_target_xcconfig = { + "CLANG_CXX_LANGUAGE_STANDARD" => rct_cxx_language_standard(), diff --git a/CHANGELOG.md b/CHANGELOG.md index a6cf4d30..3a06e7e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,132 @@ All notable changes to this project will be documented in this file. +## [3.0.4] - 2026-04-03 + +### Bug Fixes +- **split-bundle-loader**: Fix stale reflection class name for `BundleUpdateStore` in Android `getOtaBundlePath()` — updated from `expo.modules.onekeybundleupdate.BundleUpdateStore` to `com.margelo.nitro.reactnativebundleupdate.BundleUpdateStoreAndroid` +- **native-logger**: Fix dedup logic suppressing error logs — comparison now includes level, tag, and message instead of message-only +- **background-thread**: Fix JNI GlobalRef leak on each `nativeInstallSharedBridge` call — wrap in `shared_ptr` with custom deleter +- **background-thread**: Fix `SharedRPC::reset()` crash from destroying `jsi::Function` on wrong thread — use intentional leak pattern +- **background-thread**: Fix `nativeDestroy` not resetting `SharedStore`, leaving stale data across restarts +- Correct codegen class names to match TS spec file names + +### Chores +- Align all package versions to 3.x line (cloud-fs cannot use 1.x since npm already has 2.6.5) +- Bump all packages to 3.0.4 + +## [1.1.59] - 2026-04-03 + +### Bug Fixes +- **tcp-socket**: Correct header import to match codegenConfig name + +### Chores +- Bump all packages to 1.1.59 + +## [1.1.58] - 2026-04-03 + +### Bug Fixes +- **cloud-fs**: Set version to 3.0.0 (npm already has 2.6.5, cannot publish lower) + +### Chores +- Bump all packages to 1.1.58 + +## [1.1.57] - 2026-04-03 + +### Bug Fixes +- Add missing release scripts for cloud-fs, ping, zip-archive + +### Chores +- Bump all packages to 1.1.57 + +## [1.1.56] - 2026-04-03 + +### Features +- **aes-crypto / async-storage / cloud-fs / dns-lookup / network-info / ping / tcp-socket / zip-archive**: Add Android TurboModule implementations for legacy bridge module replacements +- **tcp-socket**: Fix type definitions + +### Chores +- Bump all packages to 1.1.56 + +## [1.1.55] - 2026-04-03 + +### Features +- **aes-crypto / async-storage / cloud-fs / dns-lookup / network-info / ping / tcp-socket / zip-archive**: Add TurboModule replacements for legacy React Native bridge modules (iOS + JS) + +### Chores +- Bump all packages to 1.1.55 + +## [1.1.54] - 2026-04-02 + +### Chores +- Bump all packages to 1.1.54 + +## [1.1.53] - 2026-04-02 + +### Features +- **split-bundle-loader**: Add split-bundle timing instrumentation and update PGP public key +- **split-bundle-loader**: Add comprehensive timing logs for three-bundle split verification + +### Chores +- Bump all packages to 1.1.53 + +## [1.1.52] - 2026-04-02 + +### Features +- **background-thread**: Add split-bundle common+entry loading strategy for background runtime + +### Chores +- Bump all packages to 1.1.52 + +## [1.1.51] - 2026-04-01 + +### Features +- **split-bundle-loader**: Add `resolveSegmentPath` API and path traversal protection + +### Bug Fixes +- **split-bundle-loader**: Resolve Android `registerSegmentInBackground` race condition +- **split-bundle-loader**: Enhance bridgeless support and robustness improvements + +### Chores +- Bump all packages to 1.1.51 + +## [1.1.49] - 2026-04-01 + +### Features +- **split-bundle-loader**: Add `react-native-split-bundle-loader` TurboModule with `getRuntimeBundleContext` and `loadSegment` APIs +- **split-bundle-loader**: Expose `loadSegmentInBackground` from TurboModule API +- **bundle-update**: Add `registerSegmentInBackground` for late HBC segment loading + +### Chores +- Bump all packages to 1.1.49 + +## [1.1.48] - 2026-03-31 + +### Features +- **bundle-update**: Support background bundle pair bootstrap — add `getBackgroundJsBundlePath`, metadata validation for `requiresBackgroundBundle` and `backgroundProtocolVersion`, and bundle pair compatibility checks + +### Chores +- Bump all packages to 1.1.48 + +## [1.1.47] - 2026-03-31 + +### Features +- **background-thread**: Add SharedBridge JSI HostObject for cross-runtime data transfer between main and background JS runtimes +- **background-thread**: Implement Android background runtime with second ReactHost and SharedBridge +- **background-thread**: Replace SharedBridge with SharedStore + SharedRPC architecture +- **background-thread**: Add onWrite cross-runtime notification, remove legacy messaging +- **native-logger**: Add dedup for identical consecutive log messages + +### Bug Fixes +- **background-thread**: Stabilize background thread runtime initialization +- **background-thread**: Initialize Android shared bridge at app startup +- **shared-rpc**: Rename `RuntimeExecutor` to `RPCRuntimeExecutor` to avoid React Native conflict +- **shared-rpc**: Prevent crash on JS reload by deduplicating listeners with runtimeId +- **shared-rpc**: Leak stale `jsi::Function` callback on reload to prevent crash + +### Chores +- Bump all packages to 1.1.47 + ## [1.1.46] - 2026-03-19 ### Bug Fixes diff --git a/example/react-native/.ruby-version b/example/react-native/.ruby-version new file mode 100644 index 00000000..f9892605 --- /dev/null +++ b/example/react-native/.ruby-version @@ -0,0 +1 @@ +3.4.4 diff --git a/example/react-native/Gemfile.lock b/example/react-native/Gemfile.lock new file mode 100644 index 00000000..2446f56c --- /dev/null +++ b/example/react-native/Gemfile.lock @@ -0,0 +1,141 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.8) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.8.9) + public_suffix (>= 2.0.2, < 8.0) + algoliasearch (1.27.5) + httpclient (~> 2.8, >= 2.8.3) + json (>= 1.5.1) + atomos (0.1.3) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.0) + claide (1.1.0) + cocoapods (1.15.2) + addressable (~> 2.8) + claide (>= 1.0.2, < 2.0) + cocoapods-core (= 1.15.2) + cocoapods-deintegrate (>= 1.0.3, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) + cocoapods-plugins (>= 1.0.0, < 2.0) + cocoapods-search (>= 1.0.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) + cocoapods-try (>= 1.1.0, < 2.0) + colored2 (~> 3.1) + escape (~> 0.0.4) + fourflusher (>= 2.3.0, < 3.0) + gh_inspector (~> 1.0) + molinillo (~> 0.8.0) + nap (~> 1.0) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.2) + activesupport (>= 5.0, < 8) + addressable (~> 2.8) + algoliasearch (~> 1.0) + concurrent-ruby (~> 1.1) + fuzzy_match (~> 2.0.4) + nap (~> 1.0) + netrc (~> 0.11) + public_suffix (~> 4.0) + typhoeus (~> 1.0) + cocoapods-deintegrate (1.0.5) + cocoapods-downloader (2.1) + cocoapods-plugins (1.0.0) + nap + cocoapods-search (1.0.1) + cocoapods-trunk (1.6.0) + nap (>= 0.8, < 2.0) + netrc (~> 0.11) + cocoapods-try (1.2.0) + colored2 (3.1.2) + concurrent-ruby (1.3.3) + connection_pool (3.0.2) + drb (2.2.3) + escape (0.0.4) + ethon (0.18.0) + ffi (>= 1.15.0) + logger + ffi (1.17.4) + ffi (1.17.4-aarch64-linux-gnu) + ffi (1.17.4-aarch64-linux-musl) + ffi (1.17.4-arm-linux-gnu) + ffi (1.17.4-arm-linux-musl) + ffi (1.17.4-arm64-darwin) + ffi (1.17.4-x86-linux-gnu) + ffi (1.17.4-x86-linux-musl) + ffi (1.17.4-x86_64-darwin) + ffi (1.17.4-x86_64-linux-gnu) + ffi (1.17.4-x86_64-linux-musl) + fourflusher (2.3.1) + fuzzy_match (2.0.4) + gh_inspector (1.1.3) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) + concurrent-ruby (~> 1.0) + json (2.19.3) + logger (1.7.0) + minitest (5.27.0) + molinillo (0.8.0) + mutex_m (0.3.0) + nanaimo (0.3.0) + nap (1.1.0) + netrc (0.11.0) + public_suffix (4.0.7) + rexml (3.4.4) + ruby-macho (2.5.1) + securerandom (0.4.1) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + xcodeproj (1.25.1) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.3.0) + rexml (>= 3.3.6, < 4.0) + +PLATFORMS + aarch64-linux-gnu + aarch64-linux-musl + arm-linux-gnu + arm-linux-musl + arm64-darwin + ruby + x86-linux-gnu + x86-linux-musl + x86_64-darwin + x86_64-linux-gnu + x86_64-linux-musl + +DEPENDENCIES + activesupport (>= 6.1.7.5, != 7.1.0) + benchmark + bigdecimal + cocoapods (>= 1.13, != 1.15.1, != 1.15.0) + concurrent-ruby (< 1.3.4) + logger + mutex_m + xcodeproj (< 1.26.0) + +RUBY VERSION + ruby 3.4.4p34 + +BUNDLED WITH + 2.6.9 diff --git a/example/react-native/android/app/src/main/java/com/example/MainApplication.kt b/example/react-native/android/app/src/main/java/com/example/MainApplication.kt index 41f852a5..6fea8e2e 100644 --- a/example/react-native/android/app/src/main/java/com/example/MainApplication.kt +++ b/example/react-native/android/app/src/main/java/com/example/MainApplication.kt @@ -4,8 +4,11 @@ import android.app.Application import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost +import com.facebook.react.ReactInstanceEventListener import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.bridge.ReactContext import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.backgroundthread.BackgroundThreadManager import com.margelo.nitro.nativelogger.OneKeyLog class MainApplication : Application(), ReactApplication { @@ -25,5 +28,25 @@ class MainApplication : Application(), ReactApplication { super.onCreate() OneKeyLog.info("App", "Application started") loadReactNative(this) + + // Mirror iOS AppDelegate's hostDidStart: — install SharedBridge and start + // background runner as soon as the main React context is ready. + reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + val manager = BackgroundThreadManager.getInstance() + val reactAppContext = context as com.facebook.react.bridge.ReactApplicationContext + manager.setReactPackages(PackageList(this@MainApplication).packages) + manager.installSharedBridgeInMainRuntime(reactAppContext) + + val bgURL = if (BuildConfig.DEBUG) { + // Use the same host detection as React Native (emulator vs device) + val host = com.facebook.react.modules.systeminfo.AndroidInfoHelpers.getServerHost(this@MainApplication, 8082) + "http://$host/background.bundle?platform=android&dev=true&lazy=false&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true" + } else { + "background.bundle" + } + manager.startBackgroundRunnerWithEntryURL(reactAppContext, bgURL) + } + }) } } diff --git a/example/react-native/background.js b/example/react-native/background.js index e711a188..394d41ef 100644 --- a/example/react-native/background.js +++ b/example/react-native/background.js @@ -1,72 +1,67 @@ - import ReactNative from 'react-native'; -let waitMessages = []; -const callbacks = new Set(); -const onMessageCallback = (message) => { - callbacks.forEach((callback) => callback(message)); -}; +// ── SharedRPC onWrite handler ────────────────────────────────────────── +// Background runtime listens for writes from main runtime, +// processes the RPC call, and writes the result back. -function checkReady(times = 0) { - if (globalThis.$$isNativeUiThread || times > 10_000) { - return; - } - if ( - typeof globalThis.postHostMessage === 'function' && - typeof globalThis.onHostMessage === 'function' - ) { - isReady = true; - globalThis.onHostMessage(onMessageCallback); - setTimeout(() => { - console.log('waitMessages.length', waitMessages.length); - waitMessages.forEach((message) => { - globalThis.postHostMessage(message); - }); - waitMessages = []; - }, 0); - } else { - console.log('checkReady', times); - setTimeout(() => checkReady(times + 1), 10); - } -} +let hasRegisteredRPCHandler = false; -checkReady(); +function setupRPCHandler() { + if (hasRegisteredRPCHandler || !globalThis.sharedRPC) return false; + hasRegisteredRPCHandler = true; + + globalThis.sharedRPC.onWrite((callId) => { + // Skip result writes (those are our own responses) + if (callId.endsWith(':result')) return; + + const raw = globalThis.sharedRPC.read(callId); + if (raw === undefined) return; + + let params; + try { + params = typeof raw === 'string' ? JSON.parse(raw) : raw; + } catch { + params = raw; + } -const checkThread = () => { - if (globalThis.$$isNativeUiThread) { - // eslint-disable-next-line no-restricted-syntax - throw new Error( - 'this function is not available in native background thread', + // Dispatch to handler by method name + const result = handleRPC(params); + + // Write result back — triggers main runtime's onWrite + globalThis.sharedRPC.write( + callId + ':result', + typeof result === 'string' ? result : JSON.stringify(result), ); + }); + + return true; +} + +function handleRPC(params) { + const method = params?.method; + switch (method) { + case 'echo': + return { method: 'echo', result: params.params, ts: Date.now() }; + case 'add': + return { method: 'add', result: (params.params?.a ?? 0) + (params.params?.b ?? 0) }; + case 'delay': + // Simulate async — but onWrite is synchronous, so just return immediately + return { method: 'delay', result: 'done', requestedMs: params.params?.ms }; + default: + return { error: 'unknown method', method }; } -}; +} -export const nativeBGBridge = { - postHostMessage: (message) => { - checkThread(); - if (!isReady) { - waitMessages.push(message); - return; - } - globalThis.postHostMessage(JSON.stringify(message)); - }, - onHostMessage: (callback) => { - checkThread(); - callbacks.add(callback); - return () => { - callbacks.delete(callback); - }; - }, -}; +globalThis.__setupBackgroundRPCHandler = setupRPCHandler; +setupRPCHandler(); +// ── SharedStore demo ─────────────────────────────────────────────────── +// Periodically read config values set by the main runtime. -nativeBGBridge.onHostMessage((message) => { - console.log('message', message); - if (message.type === 'test1') { - alert(`${JSON.stringify(message)} in background, wait 3 seconds`); - setTimeout(() => { - alert('post message from background thread'); - nativeBGBridge.postHostMessage({ type: 'test2' }); - }, 3000); +function pollSharedStore() { + if (globalThis.sharedStore) { + globalThis.sharedStore.get('locale'); } -}); + setTimeout(pollSharedStore, 3000); +} +pollSharedStore(); diff --git a/example/react-native/ios/Example-Bridging-header.h b/example/react-native/ios/Example-Bridging-header.h index d058d3a6..6f58c484 100644 --- a/example/react-native/ios/Example-Bridging-header.h +++ b/example/react-native/ios/Example-Bridging-header.h @@ -1,2 +1,2 @@ -#import "BackgroundRunnerModule.h" +#import "example/ReactNativeDelegate.h" #import "OneKeyLogBridge.h" diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index 5f8e4efb..864e70d7 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -32,5 +32,6 @@ target 'example' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + end end diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index d44bee95..6eb609b7 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AutoSizeInput (1.1.39): + - AutoSizeInput (1.1.46): - boost - DoubleConversion - fast_float @@ -29,7 +29,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (1.1.39): + - BackgroundThread (1.1.46): - boost - DoubleConversion - fast_float @@ -59,7 +59,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - CloudKitModule (1.1.39): + - CloudKitModule (1.1.46): - boost - DoubleConversion - fast_float @@ -96,12 +96,12 @@ PODS: - DoubleConversion (1.1.6) - fast_float (8.0.0) - FBLazyVector (0.83.0) - - fmt (11.0.2) + - fmt (12.1.0) - glog (0.3.5) - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (1.1.39): + - KeychainModule (1.1.46): - boost - DoubleConversion - fast_float @@ -199,20 +199,20 @@ PODS: - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Default (= 2024.11.18.00) - RCT-Folly/Default (2024.11.18.00): - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCT-Folly/Fabric (2024.11.18.00): - boost - DoubleConversion - fast_float (= 8.0.0) - - fmt (= 11.0.2) + - fmt (= 12.1.0) - glog - RCTDeprecation (0.83.0) - RCTRequired (0.83.0) @@ -2079,7 +2079,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (1.1.39): + - react-native-pager-view (1.1.46): - boost - DoubleConversion - fast_float @@ -2194,7 +2194,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (1.1.39): + - react-native-tab-view (1.1.46): - boost - DoubleConversion - fast_float @@ -2212,7 +2212,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 1.1.39) + - react-native-tab-view/common (= 1.1.46) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2223,7 +2223,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (1.1.39): + - react-native-tab-view/common (1.1.46): - boost - DoubleConversion - fast_float @@ -2808,7 +2808,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (1.1.39): + - ReactNativeAppUpdate (1.1.46): - boost - DoubleConversion - fast_float @@ -2839,7 +2839,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (1.1.40): + - ReactNativeBundleUpdate (1.1.46): - boost - DoubleConversion - fast_float @@ -2872,7 +2872,7 @@ PODS: - SocketRocket - SSZipArchive (~> 2.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (1.1.39): + - ReactNativeCheckBiometricAuthChanged (1.1.46): - boost - DoubleConversion - fast_float @@ -2903,7 +2903,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (1.1.39): + - ReactNativeDeviceUtils (1.1.46): - boost - DoubleConversion - fast_float @@ -2934,7 +2934,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (1.1.39): + - ReactNativeGetRandomValues (1.1.46): - boost - DoubleConversion - fast_float @@ -2965,7 +2965,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (1.1.39): + - ReactNativeLiteCard (1.1.46): - boost - DoubleConversion - fast_float @@ -2994,7 +2994,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (1.1.39): + - ReactNativeNativeLogger (1.1.46): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3025,7 +3025,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (1.1.39): + - ReactNativePerfMemory (1.1.46): - boost - DoubleConversion - fast_float @@ -3056,7 +3056,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (1.1.39): + - ReactNativeSplashScreen (1.1.46): - boost - DoubleConversion - fast_float @@ -3146,7 +3146,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (1.1.39): + - ScrollGuard (1.1.46): - boost - DoubleConversion - fast_float @@ -3176,7 +3176,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (1.1.39): + - Skeleton (1.1.46): - boost - DoubleConversion - fast_float @@ -3520,23 +3520,23 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AutoSizeInput: 4343dd8fea8c7e6347609231bb9c3f51a6867578 - BackgroundThread: 94e84403202f17cf3406f41d3737702778d3ee2b + AutoSizeInput: 547544cf73223cb8f1cfbf6619a07f6d1ee0f112 + BackgroundThread: 5f9ab77f204b961982fae9f2d025bcd645bd5216 boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - CloudKitModule: cafc8d819f8b737607281b16000217ebd6a15e02 + CloudKitModule: a57e684d58722f6b56beb86ac6c3dfb7a33f7769 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 - fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd + fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a - KeychainModule: 9a80b4db184c587526b2082290edaaec2a23aadc + KeychainModule: deb625fa54cfe814f04479c69182c7cae2bcd01e MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 + RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 RCTSwiftUI: 5928f7ca7e9e2f1a82d85d4c79ea3065137ad81c @@ -3572,9 +3572,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: 1cefe13a1ef7546f75644533226a384d20d9af9a + react-native-pager-view: 3ccf8c93ac34dffeee20e6fc73d85114e516ec64 react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: 7f93995dd29f90b5c00fafa39150ed1c66b62c80 + react-native-tab-view: 1089831dd400a821342206c1419c5c7660a66d49 React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -3608,22 +3608,22 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: 07053378aecfdaaa23877f75c9c067fef7d58415 - ReactNativeBundleUpdate: 6b4bccc4975bd20ad6b2dfbca1f21da62d2b2e8d - ReactNativeCheckBiometricAuthChanged: 3fbd6bdc758ccc059ad2feaf3214b871c0ccf31e - ReactNativeDeviceUtils: cb1fa6e62e1ab4743fd9654f0124b23637ba62c5 - ReactNativeGetRandomValues: 7b24b5b17f6a676724bb673d76a8f9b0871aa4c7 - ReactNativeLiteCard: d657d524a48c8b810e9cc330956e0db8a3ec9700 - ReactNativeNativeLogger: 80760c0c6c0f7bc53641bd893bd0595ab0ad80ab - ReactNativePerfMemory: dffc94a953191cd81884314fecd10d115b00d64b - ReactNativeSplashScreen: c32fd6f34a4cb07529db03077f924b17aa32f38b + ReactNativeAppUpdate: d5ebd5255da5c6ca99f8cde73f1e61eb6636a9f9 + ReactNativeBundleUpdate: acb8b76a23c8dffbba7acebdb2e158d20f6ee0b0 + ReactNativeCheckBiometricAuthChanged: f9065a3a6983718f4630c1fa941c77fa6e9dc708 + ReactNativeDeviceUtils: 86cb5cd345c20b56b29226283948e13b4bc631f2 + ReactNativeGetRandomValues: 3c78fac4885e336474b87f6fbb48d85334d4eaef + ReactNativeLiteCard: 8f685421ed72c443a81806fde12002b5b54cd582 + ReactNativeNativeLogger: e4213b3507a2a0f5396b6f6063753b9a47dbe74a + ReactNativePerfMemory: 0fcee15f24a08bc56203dfd69a9f13a9e2436181 + ReactNativeSplashScreen: 9011ced09b04bc4af352f537b40bdef0e24276cd RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: b4f927100c24c1d62f57de8fdbc949196eda3004 - Skeleton: 4afe54a514df5a0d0095d76e9e1f70945912280e + ScrollGuard: f87daaca0082b1b2c4967fc45b5d219cf7ec099f + Skeleton: 68d061f8700e3f54372d7428c55b0cfa11445da7 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SSZipArchive: fe6a26b2a54d5a0890f2567b5cc6de5caa600aef Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e -PODFILE CHECKSUM: 56b37b8a08f6427bcf7d82875a56b3f462979e26 +PODFILE CHECKSUM: dd92a8ce78a30e0a42b1ad8717f0b0bb35b2df5e COCOAPODS: 1.16.2 diff --git a/example/react-native/ios/example.xcodeproj/project.pbxproj b/example/react-native/ios/example.xcodeproj/project.pbxproj index 8f86577c..a832c520 100644 --- a/example/react-native/ios/example.xcodeproj/project.pbxproj +++ b/example/react-native/ios/example.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; + C19BE8E0442D0CDAE91B484A /* ReactNativeDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 77C46E7C500C7562D792C381 /* ReactNativeDelegate.mm */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,6 +23,7 @@ 074590972EF13928004098A9 /* BackgroundRunnerModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BackgroundRunnerModule.m; sourceTree = ""; }; 0773E5D62F53404600E76F7A /* OneKeyLogBridge.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneKeyLogBridge.h; sourceTree = ""; }; 0773E5D72F53404600E76F7A /* OneKeyLogBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneKeyLogBridge.m; sourceTree = ""; }; + 0DA447673C267D48DAC8B035 /* ReactNativeDelegate.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; name = ReactNativeDelegate.h; path = example/ReactNativeDelegate.h; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = example/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = example/Info.plist; sourceTree = ""; }; @@ -30,6 +32,7 @@ 5709B34CF0A7D63546082F79 /* Pods-example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.release.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.release.xcconfig"; sourceTree = ""; }; 5DCACB8F33CDC322A6C60F78 /* libPods-example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = example/AppDelegate.swift; sourceTree = ""; }; + 77C46E7C500C7562D792C381 /* ReactNativeDelegate.mm */ = {isa = PBXFileReference; includeInIndex = 1; name = ReactNativeDelegate.mm; path = example/ReactNativeDelegate.mm; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = example/LaunchScreen.storyboard; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -59,6 +62,8 @@ 074590972EF13928004098A9 /* BackgroundRunnerModule.m */, 0773E5D62F53404600E76F7A /* OneKeyLogBridge.h */, 0773E5D72F53404600E76F7A /* OneKeyLogBridge.m */, + 0DA447673C267D48DAC8B035 /* ReactNativeDelegate.h */, + 77C46E7C500C7562D792C381 /* ReactNativeDelegate.mm */, ); name = example; sourceTree = ""; @@ -261,6 +266,7 @@ 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */, 074590982EF13928004098A9 /* BackgroundRunnerModule.m in Sources */, 0773E5D82F53404600E76F7A /* OneKeyLogBridge.m in Sources */, + C19BE8E0442D0CDAE91B484A /* ReactNativeDelegate.mm in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/react-native/ios/example/AppDelegate.swift b/example/react-native/ios/example/AppDelegate.swift index 5ff0433c..3db1f359 100644 --- a/example/react-native/ios/example/AppDelegate.swift +++ b/example/react-native/ios/example/AppDelegate.swift @@ -2,6 +2,7 @@ import UIKit import React import React_RCTAppDelegate import ReactAppDependencyProvider +import BackgroundThread @main class AppDelegate: UIResponder, UIApplicationDelegate { @@ -37,17 +38,3 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return true } } - -class ReactNativeDelegate: RCTDefaultReactNativeFactoryDelegate { - override func sourceURL(for bridge: RCTBridge) -> URL? { - self.bundleURL() - } - - override func bundleURL() -> URL? { -#if DEBUG - RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") -#else - Bundle.main.url(forResource: "main", withExtension: "jsbundle") -#endif - } -} diff --git a/example/react-native/ios/example/ReactNativeDelegate.h b/example/react-native/ios/example/ReactNativeDelegate.h new file mode 100644 index 00000000..1ccdf9d2 --- /dev/null +++ b/example/react-native/ios/example/ReactNativeDelegate.h @@ -0,0 +1,14 @@ +#import + +#if __has_include() +#import +#elif __has_include() +#import +#endif + +NS_ASSUME_NONNULL_BEGIN + +@interface ReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate +@end + +NS_ASSUME_NONNULL_END diff --git a/example/react-native/ios/example/ReactNativeDelegate.mm b/example/react-native/ios/example/ReactNativeDelegate.mm new file mode 100644 index 00000000..4f6e82ee --- /dev/null +++ b/example/react-native/ios/example/ReactNativeDelegate.mm @@ -0,0 +1,35 @@ +#import "ReactNativeDelegate.h" +#import +#import +#import + +@implementation ReactNativeDelegate + +- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge +{ + return [self bundleURL]; +} + +- (NSURL *)bundleURL +{ +#if DEBUG + return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; +#else + return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; +#endif +} + +- (void)hostDidStart:(RCTHost *)host +{ + [super hostDidStart:host]; + [BackgroundThreadManager installSharedBridgeInMainRuntime:host]; + +#if DEBUG + NSString *bgURL = @"http://localhost:8082/background.bundle?platform=ios&dev=true&lazy=false&minify=false&inlineSourceMap=false&modulesOnly=false&runModule=true"; +#else + NSString *bgURL = @"background.bundle"; +#endif + [[BackgroundThreadManager sharedInstance] startBackgroundRunnerWithEntryURL:bgURL]; +} + +@end diff --git a/example/react-native/package.json b/example/react-native/package.json index ef60ee5a..7d16da94 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -33,7 +33,7 @@ "@react-navigation/native-stack": "^7.14.4", "fast-base64-decode": "*", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-mmkv": "^4.1.2", "react-native-safe-area-context": "^5.5.2", "react-native-screens": "^4.24.0" diff --git a/example/react-native/pages/BackgroundThreadTestPage.tsx b/example/react-native/pages/BackgroundThreadTestPage.tsx index 6b840335..b15fa981 100644 --- a/example/react-native/pages/BackgroundThreadTestPage.tsx +++ b/example/react-native/pages/BackgroundThreadTestPage.tsx @@ -1,32 +1,162 @@ -import React, { useState } from 'react'; -import { View, Text } from 'react-native'; -import { TestPageBase, TestButton } from './TestPageBase'; -import { BackgroundThread } from '@onekeyfe/react-native-background-thread'; - -BackgroundThread.initBackgroundThread(); +import React, { useState, useEffect, useCallback } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { TestPageBase, TestButton, TestResult } from './TestPageBase'; export function BackgroundThreadTestPage() { - const [result, setResult] = useState(''); + const [storeResult, setStoreResult] = useState(''); + const [rpcLog, setRpcLog] = useState(''); + const [ready, setReady] = useState(false); + // Wait for sharedStore & sharedRPC to be available + useEffect(() => { + const timer = setInterval(() => { + if (globalThis.sharedStore && globalThis.sharedRPC) { + setReady(true); + clearInterval(timer); + } + }, 100); + return () => clearInterval(timer); + }, []); - const handlePostMessage = () => { - const message = { type: 'test1' }; - BackgroundThread.onBackgroundMessage((event) => { - setResult(`Message received from background thread: ${event}`); + // Register onWrite listener for RPC responses from background + useEffect(() => { + if (!ready) return; + globalThis.sharedRPC!.onWrite((callId: string) => { + if (!callId.endsWith(':result')) return; + const raw = globalThis.sharedRPC?.read(callId); + if (raw === undefined) return; + const time = new Date().toLocaleTimeString(); + setRpcLog((prev) => `${prev}[${time}] ← ${raw}\n`); }); - BackgroundThread.postBackgroundMessage(JSON.stringify(message)); - setResult(`Message sent to background thread: ${JSON.stringify(message)}`); + }, [ready]); + + const appendLog = useCallback( + (msg: string) => { + const time = new Date().toLocaleTimeString(); + setRpcLog((prev) => `${prev}[${time}] → ${msg}\n`); + }, + [], + ); + + // ── SharedStore tests ──────────────────────────────────────────────── + + const handleStoreWrite = () => { + if (!globalThis.sharedStore) return; + globalThis.sharedStore.set('locale', 'zh-CN'); + globalThis.sharedStore.set('networkId', 42); + globalThis.sharedStore.set('devMode', true); + const keys = globalThis.sharedStore.keys(); + const values = keys + .map((k: string) => `${k}=${globalThis.sharedStore?.get(k)}`) + .join(', '); + setStoreResult(`Wrote 3 values → ${values} (size=${globalThis.sharedStore.size})`); + }; + + // ── SharedRPC tests ────────────────────────────────────────────────── + + const sendRPC = (method: string, params: Record) => { + if (!globalThis.sharedRPC) return; + const callId = `rpc_${Date.now()}`; + const payload = JSON.stringify({ method, params }); + globalThis.sharedRPC.write(callId, payload); + appendLog(`${method}(${JSON.stringify(params)}) id=${callId}`); }; return ( - - Result: {result} + {/* Status */} + + SharedStore / SharedRPC: {ready ? 'Ready' : 'Waiting…'} + + + {/* SharedStore */} + + SharedStore + + + + {/* SharedRPC */} + + SharedRPC (onWrite) + + sendRPC('echo', { msg: 'hello', ts: Date.now() })} + disabled={!ready} + style={styles.rowButton} + /> + sendRPC('add', { a: 3, b: 7 })} + disabled={!ready} + style={styles.rowButton} + /> + sendRPC('foo', {})} + disabled={!ready} + style={[styles.rowButton, styles.warnButton]} + /> + + setRpcLog('')} + style={styles.clearButton} + /> + {rpcLog ? ( + + {rpcLog.trimEnd()} + + ) : null} ); } + +const styles = StyleSheet.create({ + section: { + marginBottom: 20, + gap: 10, + }, + sectionTitle: { + fontSize: 16, + fontWeight: '700', + color: '#333', + }, + statusText: { + fontSize: 13, + color: '#666', + fontFamily: 'Courier New', + marginBottom: 4, + }, + buttonRow: { + flexDirection: 'row', + gap: 8, + }, + rowButton: { + flex: 1, + }, + warnButton: { + backgroundColor: '#FF9500', + }, + clearButton: { + backgroundColor: '#8E8E93', + }, + logContainer: { + backgroundColor: '#1a1a2e', + padding: 12, + borderRadius: 8, + maxHeight: 300, + }, + logText: { + fontSize: 12, + color: '#00ff88', + fontFamily: 'Courier New', + lineHeight: 18, + }, +}); diff --git a/example/react-native/pages/BundleUpdateTestPage.tsx b/example/react-native/pages/BundleUpdateTestPage.tsx index 7fe3e93e..327e8293 100644 --- a/example/react-native/pages/BundleUpdateTestPage.tsx +++ b/example/react-native/pages/BundleUpdateTestPage.tsx @@ -238,26 +238,30 @@ Hash: SHA256 { "fileName": "metadata.json", - "sha256": "8c71473ccb1c590e8c13642559600eb0c8e2649c9567236e2ac27e79e919a12c", - "size": 23123, - "generatedAt": "2025-10-22T09:50:50.446Z" + "sha256": "bf3734ac6e59388fe23c40ce2960b6fd197c596af05dd08b3ccc8b201b78c52b", + "size": 167265, + "generatedAt": "2026-03-31T03:25:05.000Z", + "appVersion": "6.1.0", + "buildNumber": "2026032032", + "bundleVersion": "7701116", + "appType": "electron" } -----BEGIN PGP SIGNATURE----- -iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmj/ZF0OHGRldkBvbmVr -ZXkuc28ACgkQs2mmepC/OHuVZhAArMmwReTpiw+XoKTw7bwlVrz0OWHfAkdh6lFY -xQpGj+AsY38NKJImrK7IQLhcnTJIwycY0a5eh8Wnqs0sxtmmwwyWQs+RHSwIdlTJ -CLpTUGxowNiD0ldz0LVLjPFqZz3/fYKkpGW1+ejkMdRXBbUrFGTa+XsEd0k3TWj2 -bxFrhy128SpQ1NJ8AXXWRzZaenFAADa5ZEJUMV4Q8sjV+C8OXtVKeW1IDXAvWEzx -x9SWU4HD4ciKYT6yRZ6RuHJ3YXFdIDPMrPXDSPTjcZUnhsadT0qFoRck6ya4uyQP -SNvEge9W9Kcup0XfKkK5SnIRyZeKgW5Zn39W8C5equqmrGy581E6R28KS3KHsE66 -Pf6WmVE/XuAKt5F++TmC6RBZ9PISPdOVhWcPZ74ySsFOUQ0nswMg1GLQ/kfixXIl -8ejFGhzhCRDmxYZ1aEJeMAAQhBuXM5TKtY79TIT9lNlttM0J/hl3rTTVxt9xSsMW -MCduz+A1mdO8T/DPqvpJksOO/YOT4gzHT9OSXNsYdte1QJKHmQdeAfzi/m66Z2/L -1qqTvwH3byXreUAjXwAWZLIbAQJ6zeeIrVKiut7DCJOHE+kGS2vdQiM2NmRFE0hP -qxdzLH784DPCWB36Xd3VZfbUxKOc06+bHlFCEXyylWD3schXV9c8Amz4DoriYIdi -Ni3q+jg= -=BVQy +iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmnLXs0OHGRldkBvbmVr +ZXkuc28ACgkQs2mmepC/OHtUkhAAoMZQc/Z1slPudePNjgO33XZwhWJNQkLeyPRL +Evz6JowioGdQjk1yJ+2jleSDDHRCceh6BzeqZqCFP58oRqug3MS4x1/7Egvza3l8 +5vW+NeX9Ai8l4PniUDcC9IwBITsVz/wzjQdhOuVbtYcP4y/48JvctBNBj5cG7cG7 +pMvOiXffUWjrBHToAKJec6V1N5L2b/2K3dutp10o3+tkfOznsHaD1vCpwxaeWcMx +W2I2SsH3uBDRYisY5W5mb5mDPbEuyqL+M+TLxHAGPwRe3+ExeipakPIJFfYsf5zi +6AnlllUv/QBH+1VZ7KauadPLD1HfMCPSbqQuTsgay56H7fvUe9khp2ysftgQ2tpc +NzTtQyZqIUeiUwBSTGqUvuLMCRChfGo7OBJE7Ec/VRzUIwGmN4Je+nY1JTYW+iR5 +cRQ9j+aNAhLYLPkdUr9hMXaDjpSdGCBM0YpEoqSOzbuZEVCD92tzdfMUI+bdC6a/ +I5cI5w1KTRKJ8irMfzm/TDcIenoUTvhzwqm+v69vFSR1LqWQMXnRvhONNTa9haov ++s+6KSUKPMH4Pa5AgRu5dkoj3UrbZUwt3tOIao97PXVXaFuSBLNhFEjS5yV+uOgK +Wfi3u5D2NWfhq0ZaV25yC16xDIe7SOXgHjNnR1vtt5L9ThZ2deidyiBJA6BFHZK6 +RNAOJKE= +=JKzr -----END PGP SIGNATURE-----` }, android: { @@ -271,30 +275,30 @@ Hash: SHA256 { "fileName": "metadata.json", - "sha256": "cece2fccea3c3a43e0da7ab803f44e5a4850a8b06f2df06db3e7f16860080a40", - "size": 28919, - "generatedAt": "2026-01-06T05:42:35.407Z", - "appType": "android", - "appVersion": "5.19.2", - "buildNumber": "2026010644", - "bundleVersion": "2" + "sha256": "bf3734ac6e59388fe23c40ce2960b6fd197c596af05dd08b3ccc8b201b78c52b", + "size": 167265, + "generatedAt": "2026-03-31T03:25:05.000Z", + "appVersion": "6.1.0", + "buildNumber": "2026032032", + "bundleVersion": "7701116", + "appType": "electron" } -----BEGIN PGP SIGNATURE----- -iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmlcvy8OHGRldkBvbmVr -ZXkuc28ACgkQs2mmepC/OHtxlA//QEvclfq0X9isJXBHFsRZx+JhfGOao60Sl0rW -m11AU6utvOAXHwxnhtLENuB2cDhhKkDrN582R2QhsdRJngRqWafwuBaVBJx+ErZV -KqvAlTj9hcLACXBw/dOyQ4JwDwm2jwloH4H//eiQdFcp1MT/uiGf3Yu9fonEC6ap -URBiiA7wAPg2o9V6zJchv/CM/xGA9G/I337lR0yAID2Y6Oteu9CftCSGqav4va4O -C94nW/wWQdt+XllY+i46mXxKOOoaIxUMP5K6q1q5CcjZBNm6Pgkay5YEDmbershP -/DeSpHwTk2APOIoaw1JVeKP8HrhIg4iGjmrUkveBoHJrGu1x6FjZ0tGqVJkG7b8f -wuBBHoPlCULbR38eFudv6UBYsJ2Zb2MEwMEC4quBczU6wg4NnOSKFK03kqsStyxG -0F+HShqAPZZrvGUUOMBMWhyxpDmbslkXtQntPhaMM8+NGqegTMiLZQnWkvzCIdoc -EYJOSlAICF/VkFzo8+LSuUgCQbqXF5qnFhcsIdzR8rguFKHC9/8/umlVjua5ilnu -bxULNIeYztMeXB29J8JXpu4efz+v5r9/HsddXlY0wMrmEWNiw+bG+ruT3O9pC9k9 -hGatKzsRToFrdoTaHG6xnKhiVH7MhQHGjvEK5KpyXIvQxy9SCIloAqs0oXrW4Yuz -H3bEFZ8= -=ZjEV +iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmnLXs0OHGRldkBvbmVr +ZXkuc28ACgkQs2mmepC/OHtUkhAAoMZQc/Z1slPudePNjgO33XZwhWJNQkLeyPRL +Evz6JowioGdQjk1yJ+2jleSDDHRCceh6BzeqZqCFP58oRqug3MS4x1/7Egvza3l8 +5vW+NeX9Ai8l4PniUDcC9IwBITsVz/wzjQdhOuVbtYcP4y/48JvctBNBj5cG7cG7 +pMvOiXffUWjrBHToAKJec6V1N5L2b/2K3dutp10o3+tkfOznsHaD1vCpwxaeWcMx +W2I2SsH3uBDRYisY5W5mb5mDPbEuyqL+M+TLxHAGPwRe3+ExeipakPIJFfYsf5zi +6AnlllUv/QBH+1VZ7KauadPLD1HfMCPSbqQuTsgay56H7fvUe9khp2ysftgQ2tpc +NzTtQyZqIUeiUwBSTGqUvuLMCRChfGo7OBJE7Ec/VRzUIwGmN4Je+nY1JTYW+iR5 +cRQ9j+aNAhLYLPkdUr9hMXaDjpSdGCBM0YpEoqSOzbuZEVCD92tzdfMUI+bdC6a/ +I5cI5w1KTRKJ8irMfzm/TDcIenoUTvhzwqm+v69vFSR1LqWQMXnRvhONNTa9haov ++s+6KSUKPMH4Pa5AgRu5dkoj3UrbZUwt3tOIao97PXVXaFuSBLNhFEjS5yV+uOgK +Wfi3u5D2NWfhq0ZaV25yC16xDIe7SOXgHjNnR1vtt5L9ThZ2deidyiBJA6BFHZK6 +RNAOJKE= +=JKzr -----END PGP SIGNATURE-----` } }) ?? { diff --git a/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/NativeLogger.kt b/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/NativeLogger.kt index 6e9bdc32..4b4ee716 100644 --- a/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/NativeLogger.kt +++ b/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/NativeLogger.kt @@ -36,6 +36,10 @@ class NativeLogger : HybridNativeLoggerSpec() { } } + override fun flushPendingRepeat() { + OneKeyLog.flushPendingRepeat() + } + override fun write(level: Double, msg: String) { val sanitized = sanitize(msg) when (level.toInt()) { diff --git a/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/OneKeyLog.kt b/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/OneKeyLog.kt index cb683dfd..83629623 100644 --- a/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/OneKeyLog.kt +++ b/native-modules/native-logger/android/src/main/java/com/margelo/nitro/nativelogger/OneKeyLog.kt @@ -243,7 +243,33 @@ object OneKeyLog { } } + // ----------------------------------------------------------------------- + // Dedup: collapse identical consecutive messages into [N repeat] + // ----------------------------------------------------------------------- + private val dedupLock = Any() + @Volatile private var prevLogKey: String? = null + private var repeatCount: Int = 0 + private fun log(tag: String, level: String, message: String, androidLogLevel: Int) { + // Dedup identical consecutive messages (same level + tag + message) + val logKey = "$level:$tag:$message" + synchronized(dedupLock) { + if (logKey == prevLogKey) { + repeatCount += 1 + return + } + val pendingRepeat = repeatCount + prevLogKey = logKey + repeatCount = 0 + + if (pendingRepeat > 0) { + val repeatMsg = "[$pendingRepeat repeat]" + val l = logger + if (l != null) l.info(repeatMsg) + else android.util.Log.i("OneKeyLog", repeatMsg) + } + } + val decision = evaluateRateLimit(level) decision.report?.let { emitRateLimitReport(it) } if (decision.drop) return @@ -283,6 +309,26 @@ object OneKeyLog { } } + /** + * Flush any pending dedup repeat summary to the log file. + * Call before log export to ensure trailing repeated messages are included. + */ + @JvmStatic + fun flushPendingRepeat() { + synchronized(dedupLock) { + val pending = repeatCount + repeatCount = 0 + prevLogKey = null + + if (pending > 0) { + val repeatMsg = "[$pending repeat]" + val l = logger + if (l != null) l.info(repeatMsg) + else android.util.Log.i("OneKeyLog", repeatMsg) + } + } + } + @JvmStatic fun debug(tag: String, message: String) { log(tag, "DEBUG", message, android.util.Log.DEBUG) } diff --git a/native-modules/native-logger/ios/NativeLogger.swift b/native-modules/native-logger/ios/NativeLogger.swift index f58606dc..0fab55dc 100644 --- a/native-modules/native-logger/ios/NativeLogger.swift +++ b/native-modules/native-logger/ios/NativeLogger.swift @@ -54,6 +54,10 @@ class NativeLogger: HybridNativeLoggerSpec { } } + func flushPendingRepeat() { + OneKeyLog.flushPendingRepeat() + } + func write(level: Double, msg: String) { let sanitized = NativeLogger.sanitize(msg) switch Int(level) { diff --git a/native-modules/native-logger/ios/OneKeyLog.swift b/native-modules/native-logger/ios/OneKeyLog.swift index c427c122..cdefcc5e 100644 --- a/native-modules/native-logger/ios/OneKeyLog.swift +++ b/native-modules/native-logger/ios/OneKeyLog.swift @@ -232,6 +232,13 @@ private class OneKeyLogFileManager: DDLogFileManagerDefault { return (true, report) } + // ----------------------------------------------------------------------- + // Dedup: collapse identical consecutive messages into [N repeat] + // ----------------------------------------------------------------------- + private static let dedupLock = NSLock() + private static var prevLogKey: String? + private static var repeatCount: Int = 0 + private static func log( _ level: String, _ tag: String, @@ -239,6 +246,25 @@ private class OneKeyLogFileManager: DDLogFileManagerDefault { writer: (String) -> Void ) { _ = configured + + // Dedup identical consecutive messages (same level + tag + message) + let logKey = "\(level):\(tag):\(message)" + dedupLock.lock() + let isDuplicate = (logKey == prevLogKey) + if isDuplicate { + repeatCount += 1 + dedupLock.unlock() + return + } + let pendingRepeat = repeatCount + prevLogKey = logKey + repeatCount = 0 + dedupLock.unlock() + + if pendingRepeat > 0 { + DDLogInfo("[\(pendingRepeat) repeat]") + } + let decision = evaluateRateLimit(for: level) if let report = decision.report { DDLogWarn(report) @@ -294,6 +320,20 @@ private class OneKeyLogFileManager: DDLogFileManagerDefault { return truncate("\(time) | \(level) : [\(safeTag)] \(safeMessage)") } + /// Flush any pending dedup repeat summary to the log file. + /// Call before log export to ensure trailing repeated messages are included. + @objc public static func flushPendingRepeat() { + dedupLock.lock() + let pending = repeatCount + repeatCount = 0 + prevLogKey = nil + dedupLock.unlock() + + if pending > 0 { + DDLogInfo("[\(pending) repeat]") + } + } + @objc public static func debug(_ tag: String, _ message: String) { log("DEBUG", tag, message) { DDLogDebug($0) } } diff --git a/native-modules/native-logger/package.json b/native-modules/native-logger/package.json index 670b8049..8ccbb6fb 100644 --- a/native-modules/native-logger/package.json +++ b/native-modules/native-logger/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-native-logger", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-native-logger", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/native-logger/src/NativeLogger.nitro.ts b/native-modules/native-logger/src/NativeLogger.nitro.ts index 7de21f2f..c849f8e7 100644 --- a/native-modules/native-logger/src/NativeLogger.nitro.ts +++ b/native-modules/native-logger/src/NativeLogger.nitro.ts @@ -3,6 +3,8 @@ import type { HybridObject } from 'react-native-nitro-modules'; export interface NativeLogger extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> { write(level: number, msg: string): void; + /** Flush any pending dedup repeat summary to the log file. Call before log export. */ + flushPendingRepeat(): void; getLogDirectory(): string; getLogFilePaths(): Promise; deleteLogFiles(): Promise; diff --git a/native-modules/react-native-aes-crypto/AesCrypto.podspec b/native-modules/react-native-aes-crypto/AesCrypto.podspec new file mode 100644 index 00000000..907b4eed --- /dev/null +++ b/native-modules/react-native-aes-crypto/AesCrypto.podspec @@ -0,0 +1,21 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "AesCrypto" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-aes-crypto.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + + s.frameworks = 'Security' + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-aes-crypto/README.md b/native-modules/react-native-aes-crypto/README.md new file mode 100644 index 00000000..c9c31480 --- /dev/null +++ b/native-modules/react-native-aes-crypto/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-aes-crypto + +First, a sincere thank-you to tectiv3 and the +`react-native-aes-crypto` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[tectiv3/react-native-aes-crypto](https://github.com/tectiv3/react-native-aes-crypto). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-aes-crypto`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-aes-crypto` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [tectiv3/react-native-aes-crypto](https://github.com/tectiv3/react-native-aes-crypto) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to tectiv3 and everyone who contributes to +`react-native-aes-crypto` 💙 diff --git a/native-modules/react-native-aes-crypto/android/build.gradle b/native-modules/react-native-aes-crypto/android/build.gradle new file mode 100644 index 00000000..034e0d6e --- /dev/null +++ b/native-modules/react-native-aes-crypto/android/build.gradle @@ -0,0 +1,80 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['AesCrypto_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["AesCrypto_" + name]).toInteger() +} + +android { + namespace "com.aescrypto" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.madgag.spongycastle:core:1.58.0.0" + implementation "com.madgag.spongycastle:prov:1.54.0.0" + implementation "com.madgag.spongycastle:pg:1.54.0.0" +} diff --git a/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoModule.kt b/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoModule.kt new file mode 100644 index 00000000..f92fa6b8 --- /dev/null +++ b/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoModule.kt @@ -0,0 +1,200 @@ +package com.aescrypto + +import android.util.Base64 +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import java.security.MessageDigest +import java.security.SecureRandom +import java.util.UUID +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec +import org.spongycastle.crypto.Digest +import org.spongycastle.crypto.digests.SHA1Digest +import org.spongycastle.crypto.digests.SHA256Digest +import org.spongycastle.crypto.digests.SHA512Digest +import org.spongycastle.crypto.generators.PKCS5S2ParametersGenerator +import org.spongycastle.crypto.params.KeyParameter +import org.spongycastle.util.encoders.Hex + +/** + * Ported from upstream react-native-aes-crypto Aes.java. + * Adapted to extend NativeAesCryptoSpec (TurboModule). + */ +@ReactModule(name = AesCryptoModule.NAME) +class AesCryptoModule(reactContext: ReactApplicationContext) : + NativeAesCryptoSpec(reactContext) { + + companion object { + const val NAME = "AesCrypto" + private const val CIPHER_CBC_ALGORITHM = "AES/CBC/PKCS7Padding" + private const val CIPHER_CTR_ALGORITHM = "AES/CTR/PKCS5Padding" + private const val HMAC_SHA_256 = "HmacSHA256" + private const val HMAC_SHA_512 = "HmacSHA512" + private const val KEY_ALGORITHM = "AES" + + private val emptyIvSpec = IvParameterSpec(ByteArray(16) { 0x00 }) + + @JvmStatic + fun bytesToHex(bytes: ByteArray): String { + val hexArray = "0123456789abcdef".toCharArray() + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = hexArray[v ushr 4] + hexChars[j * 2 + 1] = hexArray[v and 0x0F] + } + return String(hexChars) + } + } + + override fun getName(): String = NAME + + override fun encrypt(data: String, key: String, iv: String, algorithm: String, promise: Promise) { + try { + val cipherAlgorithm = if (algorithm.lowercase().contains("cbc")) CIPHER_CBC_ALGORITHM else CIPHER_CTR_ALGORITHM + val result = encryptImpl(data, key, iv, cipherAlgorithm) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun decrypt(base64: String, key: String, iv: String, algorithm: String, promise: Promise) { + try { + val cipherAlgorithm = if (algorithm.lowercase().contains("cbc")) CIPHER_CBC_ALGORITHM else CIPHER_CTR_ALGORITHM + val result = decryptImpl(base64, key, iv, cipherAlgorithm) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun pbkdf2(password: String, salt: String, cost: Double, length: Double, algorithm: String, promise: Promise) { + try { + val result = pbkdf2Impl(password, salt, cost.toInt(), length.toInt(), algorithm) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun hmac256(data: String, key: String, promise: Promise) { + try { + val result = hmacX(data, key, HMAC_SHA_256) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun hmac512(data: String, key: String, promise: Promise) { + try { + val result = hmacX(data, key, HMAC_SHA_512) + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun sha1(text: String, promise: Promise) { + try { + val result = shaX(text, "SHA-1") + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun sha256(text: String, promise: Promise) { + try { + val result = shaX(text, "SHA-256") + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun sha512(text: String, promise: Promise) { + try { + val result = shaX(text, "SHA-512") + promise.resolve(result) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun randomUuid(promise: Promise) { + try { + promise.resolve(UUID.randomUUID().toString()) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + override fun randomKey(length: Double, promise: Promise) { + try { + val key = ByteArray(length.toInt()) + SecureRandom().nextBytes(key) + promise.resolve(bytesToHex(key)) + } catch (e: Exception) { + promise.reject("-1", e.message) + } + } + + // --- Private helpers (ported from upstream Aes.java) --- + + private fun shaX(data: String, algorithm: String): String { + val md = MessageDigest.getInstance(algorithm) + md.update(Hex.decode(data)) + return bytesToHex(md.digest()) + } + + private fun pbkdf2Impl(pwd: String, salt: String, cost: Int, length: Int, algorithm: String): String { + val algorithmDigest: Digest = when { + algorithm.equals("sha1", ignoreCase = true) -> SHA1Digest() + algorithm.equals("sha256", ignoreCase = true) -> SHA256Digest() + algorithm.equals("sha512", ignoreCase = true) -> SHA512Digest() + else -> SHA512Digest() + } + val gen = PKCS5S2ParametersGenerator(algorithmDigest) + gen.init(Hex.decode(pwd), Hex.decode(salt), cost) + val key = (gen.generateDerivedParameters(length) as KeyParameter).key + return bytesToHex(key) + } + + private fun hmacX(text: String, key: String, algorithm: String): String { + val contentData = Hex.decode(text) + val akHexData = Hex.decode(key) + val mac = Mac.getInstance(algorithm) + val secretKey = SecretKeySpec(akHexData, algorithm) + mac.init(secretKey) + return bytesToHex(mac.doFinal(contentData)) + } + + private fun encryptImpl(text: String, hexKey: String, hexIv: String?, algorithm: String): String? { + if (text.isEmpty()) return null + + val key = Hex.decode(hexKey) + val secretKey = SecretKeySpec(key, KEY_ALGORITHM) + val cipher = Cipher.getInstance(algorithm) + val ivSpec = if (hexIv == null || hexIv.isEmpty()) emptyIvSpec else IvParameterSpec(Hex.decode(hexIv)) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, ivSpec) + val encrypted = cipher.doFinal(Hex.decode(text)) + return bytesToHex(encrypted) + } + + private fun decryptImpl(ciphertext: String, hexKey: String, hexIv: String?, algorithm: String): String? { + if (ciphertext.isEmpty()) return null + + val key = Hex.decode(hexKey) + val secretKey = SecretKeySpec(key, KEY_ALGORITHM) + val cipher = Cipher.getInstance(algorithm) + val ivSpec = if (hexIv == null || hexIv.isEmpty()) emptyIvSpec else IvParameterSpec(Hex.decode(hexIv)) + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivSpec) + val decrypted = cipher.doFinal(Hex.decode(ciphertext)) + return bytesToHex(decrypted) + } +} diff --git a/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoPackage.kt b/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoPackage.kt new file mode 100644 index 00000000..466ed8dd --- /dev/null +++ b/native-modules/react-native-aes-crypto/android/src/main/java/com/aescrypto/AesCryptoPackage.kt @@ -0,0 +1,33 @@ +package com.aescrypto + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class AesCryptoPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == AesCryptoModule.NAME) { + AesCryptoModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[AesCryptoModule.NAME] = ReactModuleInfo( + AesCryptoModule.NAME, + AesCryptoModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-aes-crypto/babel.config.js b/native-modules/react-native-aes-crypto/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-aes-crypto/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-aes-crypto/ios/AesCrypto.h b/native-modules/react-native-aes-crypto/ios/AesCrypto.h new file mode 100644 index 00000000..138f3786 --- /dev/null +++ b/native-modules/react-native-aes-crypto/ios/AesCrypto.h @@ -0,0 +1,56 @@ +#import + +@interface AesCrypto : NativeAesCryptoSpecBase + +- (void)encrypt:(NSString *)data + key:(NSString *)key + iv:(NSString *)iv + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)decrypt:(NSString *)base64 + key:(NSString *)key + iv:(NSString *)iv + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)pbkdf2:(NSString *)password + salt:(NSString *)salt + cost:(double)cost + length:(double)length + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)hmac256:(NSString *)base64 + key:(NSString *)key + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)hmac512:(NSString *)base64 + key:(NSString *)key + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)sha1:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)sha256:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)sha512:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)randomUuid:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)randomKey:(double)length + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-aes-crypto/ios/AesCrypto.mm b/native-modules/react-native-aes-crypto/ios/AesCrypto.mm new file mode 100644 index 00000000..db415274 --- /dev/null +++ b/native-modules/react-native-aes-crypto/ios/AesCrypto.mm @@ -0,0 +1,419 @@ +#import "AesCrypto.h" +#import +#import +#import +#import +#import + +// --------------------------------------------------------------------------- +// MARK: - Internal helpers +// --------------------------------------------------------------------------- + +static NSString *toHex(NSData *data) { + const unsigned char *bytes = (const unsigned char *)data.bytes; + NSMutableString *hex = [NSMutableString new]; + for (NSUInteger i = 0; i < data.length; i++) { + [hex appendFormat:@"%02x", bytes[i]]; + } + return [hex copy]; +} + +static NSData *fromHex(NSString *string) { + NSMutableData *data = [[NSMutableData alloc] init]; + unsigned char whole_byte; + char byte_chars[3] = {'\0', '\0', '\0'}; + NSUInteger len = string.length / 2; + for (NSUInteger i = 0; i < len; i++) { + byte_chars[0] = (char)[string characterAtIndex:i * 2]; + byte_chars[1] = (char)[string characterAtIndex:i * 2 + 1]; + whole_byte = (unsigned char)strtol(byte_chars, NULL, 16); + [data appendBytes:&whole_byte length:1]; + } + return data; +} + +static NSData *aesCBC(NSString *operation, NSData *inputData, NSString *key, NSString *iv, NSString *algorithm) { + NSData *keyData = fromHex(key); + NSData *ivData = fromHex(iv); + + NSArray *algorithms = @[@"aes-128-cbc", @"aes-192-cbc", @"aes-256-cbc"]; + NSUInteger item = [algorithms indexOfObject:algorithm]; + size_t keyLength; + switch (item) { + case 0: keyLength = kCCKeySizeAES128; break; + case 1: keyLength = kCCKeySizeAES192; break; + default: keyLength = kCCKeySizeAES256; break; + } + + NSMutableData *buffer = [[NSMutableData alloc] initWithLength:inputData.length + kCCBlockSizeAES128]; + size_t numBytes = 0; + + CCCryptorStatus status = CCCrypt( + [operation isEqualToString:@"encrypt"] ? kCCEncrypt : kCCDecrypt, + kCCAlgorithmAES, + kCCOptionPKCS7Padding, + keyData.bytes, keyLength, + ivData.length ? ivData.bytes : nil, + inputData.bytes, inputData.length, + buffer.mutableBytes, buffer.length, + &numBytes); + + if (status == kCCSuccess) { + [buffer setLength:numBytes]; + return buffer; + } + return nil; +} + +static NSData *aesCTR(NSString *operation, NSData *inputData, NSString *key, NSString *iv, NSString *algorithm) { + NSData *keyData = fromHex(key); + NSData *ivData = fromHex(iv); + + NSArray *algorithms = @[@"aes-128-ctr", @"aes-192-ctr", @"aes-256-ctr"]; + NSUInteger item = [algorithms indexOfObject:algorithm]; + size_t keyLength; + switch (item) { + case 0: keyLength = kCCKeySizeAES128; break; + case 1: keyLength = kCCKeySizeAES192; break; + default: keyLength = kCCKeySizeAES256; break; + } + + NSMutableData *buffer = [[NSMutableData alloc] initWithLength:inputData.length + kCCBlockSizeAES128]; + size_t numBytes = 0; + CCCryptorRef cryptor = NULL; + + CCCryptorStatus status = CCCryptorCreateWithMode( + [operation isEqualToString:@"encrypt"] ? kCCEncrypt : kCCDecrypt, + kCCModeCTR, + kCCAlgorithmAES, + ccPKCS7Padding, + ivData.length ? ivData.bytes : nil, + keyData.bytes, + keyLength, + NULL, 0, 0, + kCCModeOptionCTR_BE, + &cryptor); + + if (status != kCCSuccess) { + return nil; + } + + CCCryptorStatus updateStatus = CCCryptorUpdate( + cryptor, + inputData.bytes, inputData.length, + buffer.mutableBytes, buffer.length, + &numBytes); + + if (updateStatus != kCCSuccess) { + CCCryptorRelease(cryptor); + return nil; + } + buffer.length = numBytes; + + size_t finalBytes = 0; + CCCryptorStatus finalStatus = CCCryptorFinal( + cryptor, + buffer.mutableBytes, buffer.length, + &finalBytes); + + CCCryptorRelease(cryptor); + + if (finalStatus != kCCSuccess) { + return nil; + } + return buffer; +} + +// --------------------------------------------------------------------------- +// MARK: - TurboModule implementation +// --------------------------------------------------------------------------- + +@implementation AesCrypto + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"AesCrypto"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - encrypt + +- (void)encrypt:(NSString *)data + key:(NSString *)key + iv:(NSString *)iv + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *inputData = fromHex(data); + NSData *result = nil; + if ([algorithm containsString:@"ctr"]) { + result = aesCTR(@"encrypt", inputData, key, iv, algorithm); + } else { + result = aesCBC(@"encrypt", inputData, key, iv, algorithm); + } + if (result == nil) { + reject(@"encrypt_fail", @"Encrypt error", nil); + } else { + resolve(toHex(result)); + } + } @catch (NSException *exception) { + reject(@"encrypt_fail", exception.reason, nil); + } + }); +} + +// MARK: - decrypt + +- (void)decrypt:(NSString *)base64 + key:(NSString *)key + iv:(NSString *)iv + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *inputData = fromHex(base64); + NSData *result = nil; + if ([algorithm containsString:@"ctr"]) { + result = aesCTR(@"decrypt", inputData, key, iv, algorithm); + } else { + result = aesCBC(@"decrypt", inputData, key, iv, algorithm); + } + if (result == nil) { + reject(@"decrypt_fail", @"Decrypt failed", nil); + } else { + resolve(toHex(result)); + } + } @catch (NSException *exception) { + reject(@"decrypt_fail", exception.reason, nil); + } + }); +} + +// MARK: - pbkdf2 + +- (void)pbkdf2:(NSString *)password + salt:(NSString *)salt + cost:(double)cost + length:(double)length + algorithm:(NSString *)algorithm + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *passwordData = fromHex(password); + NSData *saltData = fromHex(salt); + // length is in bits, convert to bytes + NSUInteger keyLengthBytes = (NSUInteger)(length / 8); + NSMutableData *hashKeyData = [NSMutableData dataWithLength:keyLengthBytes]; + + CCPseudoRandomAlgorithm prf = kCCPRFHmacAlgSHA512; + NSString *algoLower = [algorithm lowercaseString]; + if ([algoLower isEqualToString:@"sha1"]) { + prf = kCCPRFHmacAlgSHA1; + } else if ([algoLower isEqualToString:@"sha256"]) { + prf = kCCPRFHmacAlgSHA256; + } else if ([algoLower isEqualToString:@"sha512"]) { + prf = kCCPRFHmacAlgSHA512; + } + + int status = CCKeyDerivationPBKDF( + kCCPBKDF2, + (const char *)passwordData.bytes, passwordData.length, + (const uint8_t *)saltData.bytes, saltData.length, + prf, + (unsigned int)cost, + (uint8_t *)hashKeyData.mutableBytes, hashKeyData.length); + + if (status == kCCParamError) { + reject(@"keygen_fail", @"Key derivation parameter error", nil); + } else { + resolve(toHex(hashKeyData)); + } + } @catch (NSException *exception) { + reject(@"keygen_fail", exception.reason, nil); + } + }); +} + +// MARK: - hmac256 + +- (void)hmac256:(NSString *)base64 + key:(NSString *)key + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *keyData = fromHex(key); + NSData *inputData = fromHex(base64); + void *buffer = malloc(CC_SHA256_DIGEST_LENGTH); + if (!buffer) { + reject(@"hmac_fail", @"Memory allocation error", nil); + return; + } + CCHmac(kCCHmacAlgSHA256, + keyData.bytes, keyData.length, + inputData.bytes, inputData.length, + buffer); + NSData *result = [NSData dataWithBytesNoCopy:buffer + length:CC_SHA256_DIGEST_LENGTH + freeWhenDone:YES]; + resolve(toHex(result)); + } @catch (NSException *exception) { + reject(@"hmac_fail", exception.reason, nil); + } + }); +} + +// MARK: - hmac512 + +- (void)hmac512:(NSString *)base64 + key:(NSString *)key + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *keyData = fromHex(key); + NSData *inputData = fromHex(base64); + void *buffer = malloc(CC_SHA512_DIGEST_LENGTH); + if (!buffer) { + reject(@"hmac_fail", @"Memory allocation error", nil); + return; + } + CCHmac(kCCHmacAlgSHA512, + keyData.bytes, keyData.length, + inputData.bytes, inputData.length, + buffer); + NSData *result = [NSData dataWithBytesNoCopy:buffer + length:CC_SHA512_DIGEST_LENGTH + freeWhenDone:YES]; + resolve(toHex(result)); + } @catch (NSException *exception) { + reject(@"hmac_fail", exception.reason, nil); + } + }); +} + +// MARK: - sha1 + +- (void)sha1:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *inputData = fromHex(text); + NSMutableData *result = [[NSMutableData alloc] initWithLength:CC_SHA1_DIGEST_LENGTH]; + CC_SHA1((const void *)inputData.bytes, (CC_LONG)inputData.length, (unsigned char *)result.mutableBytes); + resolve(toHex(result)); + } @catch (NSException *exception) { + reject(@"sha1_fail", exception.reason, nil); + } + }); +} + +// MARK: - sha256 + +- (void)sha256:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *inputData = fromHex(text); + unsigned char *buffer = (unsigned char *)malloc(CC_SHA256_DIGEST_LENGTH); + if (!buffer) { + reject(@"sha256_fail", @"Memory allocation error", nil); + return; + } + CC_SHA256((const void *)inputData.bytes, (CC_LONG)inputData.length, buffer); + NSData *result = [NSData dataWithBytesNoCopy:buffer + length:CC_SHA256_DIGEST_LENGTH + freeWhenDone:YES]; + resolve(toHex(result)); + } @catch (NSException *exception) { + reject(@"sha256_fail", exception.reason, nil); + } + }); +} + +// MARK: - sha512 + +- (void)sha512:(NSString *)text + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *inputData = fromHex(text); + unsigned char *buffer = (unsigned char *)malloc(CC_SHA512_DIGEST_LENGTH); + if (!buffer) { + reject(@"sha512_fail", @"Memory allocation error", nil); + return; + } + CC_SHA512((const void *)inputData.bytes, (CC_LONG)inputData.length, buffer); + NSData *result = [NSData dataWithBytesNoCopy:buffer + length:CC_SHA512_DIGEST_LENGTH + freeWhenDone:YES]; + resolve(toHex(result)); + } @catch (NSException *exception) { + reject(@"sha512_fail", exception.reason, nil); + } + }); +} + +// MARK: - randomUuid + +- (void)randomUuid:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *uuid = [[NSUUID UUID] UUIDString]; + resolve(uuid); + } @catch (NSException *exception) { + reject(@"uuid_fail", exception.reason, nil); + } + }); +} + +// MARK: - randomKey + +- (void)randomKey:(double)length + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSUInteger len = (NSUInteger)length; + NSMutableData *data = [NSMutableData dataWithLength:len]; + int result = SecRandomCopyBytes(kSecRandomDefault, len, data.mutableBytes); + if (result != errSecSuccess) { + reject(@"random_fail", @"Random key generation error", nil); + } else { + resolve(toHex(data)); + } + } @catch (NSException *exception) { + reject(@"random_fail", exception.reason, nil); + } + }); +} + +@end diff --git a/native-modules/react-native-aes-crypto/package.json b/native-modules/react-native-aes-crypto/package.json new file mode 100644 index 00000000..292615aa --- /dev/null +++ b/native-modules/react-native-aes-crypto/package.json @@ -0,0 +1,162 @@ +{ + "name": "@onekeyfe/react-native-aes-crypto", + "version": "3.0.18", + "description": "react-native-aes-crypto", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-aes-crypto.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-aes-crypto/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-aes-crypto#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "AesCryptoSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.aescrypto" + }, + "ios": { + "modulesProvider": { + "AesCrypto": "AesCrypto" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-aes-crypto/src/NativeAesCrypto.ts b/native-modules/react-native-aes-crypto/src/NativeAesCrypto.ts new file mode 100644 index 00000000..27465870 --- /dev/null +++ b/native-modules/react-native-aes-crypto/src/NativeAesCrypto.ts @@ -0,0 +1,33 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + encrypt( + data: string, + key: string, + iv: string, + algorithm: string, + ): Promise; + decrypt( + base64: string, + key: string, + iv: string, + algorithm: string, + ): Promise; + pbkdf2( + password: string, + salt: string, + cost: number, + length: number, + algorithm: string, + ): Promise; + hmac256(base64: string, key: string): Promise; + hmac512(base64: string, key: string): Promise; + sha1(text: string): Promise; + sha256(text: string): Promise; + sha512(text: string): Promise; + randomUuid(): Promise; + randomKey(length: number): Promise; +} + +export default TurboModuleRegistry.getEnforcing('AesCrypto'); diff --git a/native-modules/react-native-aes-crypto/src/index.tsx b/native-modules/react-native-aes-crypto/src/index.tsx new file mode 100644 index 00000000..9c27e533 --- /dev/null +++ b/native-modules/react-native-aes-crypto/src/index.tsx @@ -0,0 +1,15 @@ +import NativeAesCrypto from './NativeAesCrypto'; + +export const encrypt = NativeAesCrypto.encrypt.bind(NativeAesCrypto); +export const decrypt = NativeAesCrypto.decrypt.bind(NativeAesCrypto); +export const pbkdf2 = NativeAesCrypto.pbkdf2.bind(NativeAesCrypto); +export const hmac256 = NativeAesCrypto.hmac256.bind(NativeAesCrypto); +export const hmac512 = NativeAesCrypto.hmac512.bind(NativeAesCrypto); +export const sha1 = NativeAesCrypto.sha1.bind(NativeAesCrypto); +export const sha256 = NativeAesCrypto.sha256.bind(NativeAesCrypto); +export const sha512 = NativeAesCrypto.sha512.bind(NativeAesCrypto); +export const randomUuid = NativeAesCrypto.randomUuid.bind(NativeAesCrypto); +export const randomKey = NativeAesCrypto.randomKey.bind(NativeAesCrypto); + +export default NativeAesCrypto; +export type { Spec as AesCryptoSpec } from './NativeAesCrypto'; diff --git a/native-modules/react-native-aes-crypto/tsconfig.build.json b/native-modules/react-native-aes-crypto/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-aes-crypto/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-aes-crypto/tsconfig.json b/native-modules/react-native-aes-crypto/tsconfig.json new file mode 100644 index 00000000..b8eac8b1 --- /dev/null +++ b/native-modules/react-native-aes-crypto/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-aes-crypto": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-aes-crypto/turbo.json b/native-modules/react-native-aes-crypto/turbo.json new file mode 100644 index 00000000..e094f154 --- /dev/null +++ b/native-modules/react-native-aes-crypto/turbo.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt index da579cc6..ab129afa 100644 --- a/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt +++ b/native-modules/react-native-app-update/android/src/main/java/com/margelo/nitro/reactnativeappupdate/ReactNativeAppUpdate.kt @@ -65,31 +65,43 @@ fpqe0vKYUlT092joT0o6nT2MzmLmHUW0kDqD9p6JEJEZUZpqcSRE84eMTFNyu966 xy/rjN2SMJTFzkNXPkwXYrMYoahGez1oZfLzV6SQ0+blNc3aATt9aQW6uaCZtMw1 ibcfWW9neHVpRtTlMYCoa2reGaBGCv0Nd8pMcyFUQkVaes5cQHkh3r5Dba+YrVvp l4P8HMbN8/LqAv7eBfj3ylPa/8eEPWVifcum2Y9TqherN1C2JDqWIpH4EsApek3k -NMK6q0lPxXjZ3Pa5Ag0EYkBMbAEQAM1R4N3bBkwKkHeYwsQASevUkHwY4eg6Ncgp -f9NbmJHcEioqXTIv0nHCQbos3P2NhXvDowj4JFkK/ZbpP9yo0p7TI4fckseVSWwI -tiF9l/8OmXvYZMtw3hHcUUZVdJnk0xrqT6ni6hyRFIfbqous6/vpqi0GG7nB/+lU -E5StGN8696ZWRyAX9MmwoRoods3ShNJP0+GCYHfIcG0XRhEDMJph+7mWPlkQUcza -4aEjxOQ4Stwwp+ZL1rXSlyJIPk1S9/FIS/Uw5GgqFJXIf5n+SCVtUZ8lGedEWwe4 -wXsoPFxxOc2Gqw5r4TrJFdgA3MptYebXmb2LGMssXQTM1AQS2LdpnWw44+X1CHvQ -0m4pEw/g2OgeoJPBurVUnu2mU/M+ARZiS4ceAR0pLZN7Yq48p1wr6EOBQdA3Usby -uc17MORG/IjRmjz4SK/luQLXjN+0jwQSoM1kcIHoRk37B8feHjVufJDKlqtw83H1 -uNu6lGwb8MxDgTuuHloDijCDQsn6m7ZKU1qqLDGtdvCUY2ovzuOUS9vv6MAhR86J -kqoU3sOBMeQhnBaTNKU0IjT4M+ERCWQ7MewlzXuPHgyb4xow1SKZny+f+fYXPy9+ -hx4/j5xaKrZKdq5zIo+GRGe4lA088l253nGeLgSnXsbSxqADqKK73d7BXLCVEZHx -f4Sa5JN7ABEBAAGJAjwEGAEIACYWIQTraK5UTx/djNJkYk+zaaZ6kL84ewUCYkBM -bAIbDAUJB4YfRAAKCRCzaaZ6kL84e0UGD/4mVWyGoQC86TyPoU4Pb5r8mynXWmiH -ZGKu2ll8qn3l5Q67OophgbA1I0GTBFsYK2f91ahgs7FEsLrmz/25E8ybcdJipITE -6869nyE1b37jVb3z3BJLYS/4MaNvugNz4VjMHWVAL52glXLN+SJBSNscmWZDKnVn -Rnrn+kBEvOWZgLbi4MpPiNVwm2PGnrtPzudTcg/NS3HOcmJTfG3mrnwwNJybTVAx -txlQPoXUpJQqJjtkPPW+CqosolpRdugQ5zpFSg05iL+vN+CMrVPkk85w87dtsidl -yZl/ZNITrLzym9d2UFVQZY2rRohNdRfx3l4rfXJFLaqQtihRvBIiMKTbUb2V0pd3 -rVLz2Ck3gJqPfPEEmCWS0Nx6rME8m0sOkNyMau3dMUUAs4j2c3pOQmsZRjKo7LAc -7/GahKFhZ2aBCQzvcTES+gPH1Z5HnivkcnUF2gnQV9x7UOr1Q/euKJsxPl5CCZtM -N9GFW10cDxFo7cO5Ch+/BkkkfebuI/4Wa1SQTzawsxTx4eikKwcemgfDsyIqRs2W -62PBrqCzs9Tg19l35sCdmvYsvMadrYFXukHXiUKEpwJMdTLAtjJ+AX84YLwuHi3+ -qZ5okRCqZH+QpSojSScT9H5ze4ZpuP0d8pKycxb8M2RfYdyOtT/eqsZ/1EQPg7kq -P2Q5dClenjjjVA== -=F0np +NMK6q0lPxXjZ3PaJAlQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AW +IQTraK5UTx/djNJkYk+zaaZ6kL84ewUCactdeAUJDxpqwwAKCRCzaaZ6kL84e8TX +EACtuZUT79PZx964iUf6T04IZ/SFqftMdIPrvCOpyYUkzFfTjufZSP7S5dmut/dl +VLQnPjip0ZGeHeSX2ersXmmp7Ny2zqZr858ZIdLpamkEg6hRi5LWOOK4clnKzTLe +OGWlA6WzF3cb4YB4NiNOX1yxxtggZrndyMxLfSU27aZ4h98/g5j/o/FRCt0OzibH +IGKl+tUayKEEtq7+CrxWHwCXY+wFeeJFm2yhEMqeAZlVpsvGgtfWevQwHaRcld99 +5ousZOOqsCkl1J7rCeaIFowIEA3TzH0FWIQGahGiHN/+zwc7iSIL9gNEq4/AYJWK +80jPqyrRDia7VfZA/SULbWaPmmqrn/Y8qYl3jDvT/6BuwXFAgK9pz5NkWggkjAMX +nGylez9tZBfv+Bymv5RTRAHey49noF/6ZcF5fidtXAS2tfhuRIlOUfEY+QyB3lXj +kxeOOAGJ2ejTVBVIJnfoSFSsG+LH1tvzbDJvNQcMh0oQD849fip+6O0Ae3KfNZpw +aNkIdxThvBU0XCPgmyEXll/mkS5QlUQUo+EwbZOjr6xGmi310DgJo3Ry1dfZ8qBq +F3DD6NK40bkfw8I6Qjwf/IXd921ZbKe88UMjVBTpm2IH3WXR51My9LN/2gzV9zL+ +7odaaXfd+u2x9RuZ1caLXSv4Qyc/7Le1d2T4LpevA7GwMrkCDQRiQExsARAAzVHg +3dsGTAqQd5jCxABJ69SQfBjh6Do1yCl/01uYkdwSKipdMi/SccJBuizc/Y2Fe8Oj +CPgkWQr9luk/3KjSntMjh9ySx5VJbAi2IX2X/w6Ze9hky3DeEdxRRlV0meTTGupP +qeLqHJEUh9uqi6zr++mqLQYbucH/6VQTlK0Y3zr3plZHIBf0ybChGih2zdKE0k/T +4YJgd8hwbRdGEQMwmmH7uZY+WRBRzNrhoSPE5DhK3DCn5kvWtdKXIkg+TVL38UhL +9TDkaCoUlch/mf5IJW1RnyUZ50RbB7jBeyg8XHE5zYarDmvhOskV2ADcym1h5teZ +vYsYyyxdBMzUBBLYt2mdbDjj5fUIe9DSbikTD+DY6B6gk8G6tVSe7aZT8z4BFmJL +hx4BHSktk3tirjynXCvoQ4FB0DdSxvK5zXsw5Eb8iNGaPPhIr+W5AteM37SPBBKg +zWRwgehGTfsHx94eNW58kMqWq3DzcfW427qUbBvwzEOBO64eWgOKMINCyfqbtkpT +WqosMa128JRjai/O45RL2+/owCFHzomSqhTew4Ex5CGcFpM0pTQiNPgz4REJZDsx +7CXNe48eDJvjGjDVIpmfL5/59hc/L36HHj+PnFoqtkp2rnMij4ZEZ7iUDTzyXbne +cZ4uBKdextLGoAOoorvd3sFcsJURkfF/hJrkk3sAEQEAAYkCPAQYAQgAJhYhBOto +rlRPH92M0mRiT7NppnqQvzh7BQJiQExsAhsMBQkHhh9EAAoJELNppnqQvzh7RQYP +/iZVbIahALzpPI+hTg9vmvybKddaaIdkYq7aWXyqfeXlDrs6imGBsDUjQZMEWxgr +Z/3VqGCzsUSwuubP/bkTzJtx0mKkhMTrzr2fITVvfuNVvfPcEkthL/gxo2+6A3Ph +WMwdZUAvnaCVcs35IkFI2xyZZkMqdWdGeuf6QES85ZmAtuLgyk+I1XCbY8aeu0/O +51NyD81Lcc5yYlN8beaufDA0nJtNUDG3GVA+hdSklComO2Q89b4KqiyiWlF26BDn +OkVKDTmIv6834IytU+STznDzt22yJ2XJmX9k0hOsvPKb13ZQVVBljatGiE11F/He +Xit9ckUtqpC2KFG8EiIwpNtRvZXSl3etUvPYKTeAmo988QSYJZLQ3HqswTybSw6Q +3Ixq7d0xRQCziPZzek5CaxlGMqjssBzv8ZqEoWFnZoEJDO9xMRL6A8fVnkeeK+Ry +dQXaCdBX3HtQ6vVD964omzE+XkIJm0w30YVbXRwPEWjtw7kKH78GSSR95u4j/hZr +VJBPNrCzFPHh6KQrBx6aB8OzIipGzZbrY8GuoLOz1ODX2XfmwJ2a9iy8xp2tgVe6 +QdeJQoSnAkx1MsC2Mn4BfzhgvC4eLf6pnmiREKpkf5ClKiNJJxP0fnN7hmm4/R3y +krJzFvwzZF9h3I61P96qxn/URA+DuSo/ZDl0KV6eOONU +=HlTQ -----END PGP PUBLIC KEY BLOCK-----""" private data class Listener( diff --git a/native-modules/react-native-app-update/package.json b/native-modules/react-native-app-update/package.json index 217ddb9d..1d09d144 100644 --- a/native-modules/react-native-app-update/package.json +++ b/native-modules/react-native-app-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-app-update", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-app-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-async-storage/AsyncStorage.podspec b/native-modules/react-native-async-storage/AsyncStorage.podspec new file mode 100644 index 00000000..f9de49d6 --- /dev/null +++ b/native-modules/react-native-async-storage/AsyncStorage.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "AsyncStorage" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-async-storage.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-async-storage/README.md b/native-modules/react-native-async-storage/README.md new file mode 100644 index 00000000..c64a6ea8 --- /dev/null +++ b/native-modules/react-native-async-storage/README.md @@ -0,0 +1,32 @@ +# @onekeyfe/react-native-async-storage + +First, a sincere thank-you to Krzysztof Borowy and the +`@react-native-async-storage/async-storage` maintainers for their excellent +work 🙏 + +This package is built on, and inspired by, +[react-native-async-storage/async-storage](https://github.com/react-native-async-storage/async-storage). + +Our original plan was to keep our customizations as patches on top of upstream +`@react-native-async-storage/async-storage`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `async-storage` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [react-native-async-storage/async-storage](https://github.com/react-native-async-storage/async-storage) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to Krzysztof Borowy and everyone who contributes to +`@react-native-async-storage/async-storage` 💙 diff --git a/native-modules/react-native-async-storage/android/build.gradle b/native-modules/react-native-async-storage/android/build.gradle new file mode 100644 index 00000000..0f6dca54 --- /dev/null +++ b/native-modules/react-native-async-storage/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['AsyncStorage_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["AsyncStorage_" + name]).toInteger() +} + +android { + namespace "com.asyncstorage" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStorageModule.kt b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStorageModule.kt new file mode 100644 index 00000000..a82dd058 --- /dev/null +++ b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStorageModule.kt @@ -0,0 +1,368 @@ +package com.asyncstorage + +import android.database.Cursor +import android.database.sqlite.SQLiteStatement +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.bridge.WritableArray +import com.facebook.react.module.annotations.ReactModule +import org.json.JSONObject +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +/** + * Ported from upstream @react-native-async-storage/async-storage AsyncStorageModule.java + * Adapted to use Promise (TurboModule) instead of Callback. + * Uses SQLite via ReactDatabaseSupplier (same as upstream). + */ +@ReactModule(name = RNCAsyncStorageModule.NAME) +class RNCAsyncStorageModule(reactContext: ReactApplicationContext) : + NativeAsyncStorageSpec(reactContext) { + + companion object { + const val NAME = "RNCAsyncStorage" + // SQL variable number limit, defined by SQLITE_LIMIT_VARIABLE_NUMBER + private const val MAX_SQL_KEYS = 999 + } + + private val dbSupplier: ReactDatabaseSupplier = ReactDatabaseSupplier.getInstance(reactContext) + private val executor: Executor = SerialExecutor(Executors.newSingleThreadExecutor()) + @Volatile + private var shuttingDown = false + + override fun getName(): String = NAME + + override fun initialize() { + super.initialize() + shuttingDown = false + } + + override fun invalidate() { + shuttingDown = true + dbSupplier.closeDatabase() + } + + override fun multiGet(keys: ReadableArray, promise: Promise) { + executor.execute { + try { + if (!ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + + val columns = arrayOf(ReactDatabaseSupplier.KEY_COLUMN, ReactDatabaseSupplier.VALUE_COLUMN) + val keysRemaining = HashSet() + val data: WritableArray = Arguments.createArray() + + var keyStart = 0 + while (keyStart < keys.size()) { + val keyCount = Math.min(keys.size() - keyStart, MAX_SQL_KEYS) + val cursor: Cursor = dbSupplier.get().query( + ReactDatabaseSupplier.TABLE_CATALYST, + columns, + buildKeySelection(keyCount), + buildKeySelectionArgs(keys, keyStart, keyCount), + null, null, null + ) + + keysRemaining.clear() + try { + if (cursor.count != keys.size()) { + for (keyIndex in keyStart until keyStart + keyCount) { + keysRemaining.add(keys.getString(keyIndex) ?: "") + } + } + if (cursor.moveToFirst()) { + do { + val row = Arguments.createArray() + row.pushString(cursor.getString(0)) + row.pushString(cursor.getString(1)) + data.pushArray(row) + keysRemaining.remove(cursor.getString(0)) + } while (cursor.moveToNext()) + } + } finally { + cursor.close() + } + + for (key in keysRemaining) { + val row = Arguments.createArray() + row.pushString(key) + row.pushNull() + data.pushArray(row) + } + keysRemaining.clear() + keyStart += MAX_SQL_KEYS + } + + promise.resolve(data) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + override fun multiSet(keyValuePairs: ReadableArray, promise: Promise) { + if (keyValuePairs.size() == 0) { + promise.resolve(null) + return + } + + executor.execute { + try { + if (!ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + + val sql = "INSERT OR REPLACE INTO ${ReactDatabaseSupplier.TABLE_CATALYST} VALUES (?, ?);" + val statement: SQLiteStatement = dbSupplier.get().compileStatement(sql) + try { + dbSupplier.get().beginTransaction() + for (idx in 0 until keyValuePairs.size()) { + val pair = keyValuePairs.getArray(idx) + if (pair == null || pair.size() != 2) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value") + return@execute + } + val key = pair.getString(0) + val value = pair.getString(1) + if (key == null) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid key") + return@execute + } + if (value == null) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value") + return@execute + } + statement.clearBindings() + statement.bindString(1, key) + statement.bindString(2, value) + statement.execute() + } + dbSupplier.get().setTransactionSuccessful() + } finally { + try { + dbSupplier.get().endTransaction() + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + return@execute + } + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + override fun multiRemove(keys: ReadableArray, promise: Promise) { + if (keys.size() == 0) { + promise.resolve(null) + return + } + + executor.execute { + try { + if (!ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + + try { + dbSupplier.get().beginTransaction() + var keyStart = 0 + while (keyStart < keys.size()) { + val keyCount = Math.min(keys.size() - keyStart, MAX_SQL_KEYS) + dbSupplier.get().delete( + ReactDatabaseSupplier.TABLE_CATALYST, + buildKeySelection(keyCount), + buildKeySelectionArgs(keys, keyStart, keyCount) + ) + keyStart += MAX_SQL_KEYS + } + dbSupplier.get().setTransactionSuccessful() + } finally { + try { + dbSupplier.get().endTransaction() + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + return@execute + } + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + override fun multiMerge(keyValuePairs: ReadableArray, promise: Promise) { + executor.execute { + try { + if (!ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + + try { + dbSupplier.get().beginTransaction() + for (idx in 0 until keyValuePairs.size()) { + val pair = keyValuePairs.getArray(idx) + if (pair == null || pair.size() != 2) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value") + return@execute + } + val key = pair.getString(0) + val value = pair.getString(1) + if (key == null) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid key") + return@execute + } + if (value == null) { + promise.reject("ASYNC_STORAGE_ERROR", "Invalid Value") + return@execute + } + if (!mergeImpl(key, value)) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + } + dbSupplier.get().setTransactionSuccessful() + } finally { + try { + dbSupplier.get().endTransaction() + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + return@execute + } + } + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + override fun getAllKeys(promise: Promise) { + executor.execute { + try { + if (!ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + + val columns = arrayOf(ReactDatabaseSupplier.KEY_COLUMN) + val cursor: Cursor = dbSupplier.get().query( + ReactDatabaseSupplier.TABLE_CATALYST, + columns, null, null, null, null, null + ) + + val data: WritableArray = Arguments.createArray() + try { + if (cursor.moveToFirst()) { + do { + data.pushString(cursor.getString(0)) + } while (cursor.moveToNext()) + } + } finally { + cursor.close() + } + promise.resolve(data) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + override fun clear(promise: Promise) { + executor.execute { + try { + if (!dbSupplier.ensureDatabase()) { + promise.reject("ASYNC_STORAGE_ERROR", "Database Error") + return@execute + } + dbSupplier.clear() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("ASYNC_STORAGE_ERROR", e.message, e) + } + } + } + + // --- Private helpers (ported from AsyncLocalStorageUtil.java) --- + + private fun ensureDatabase(): Boolean { + return !shuttingDown && dbSupplier.ensureDatabase() + } + + private fun buildKeySelection(count: Int): String { + val list = Array(count) { "?" } + return "${ReactDatabaseSupplier.KEY_COLUMN} IN (${list.joinToString(", ")})" + } + + private fun buildKeySelectionArgs(keys: ReadableArray, start: Int, count: Int): Array { + return Array(count) { keys.getString(start + it) ?: "" } + } + + private fun getItemImpl(key: String): String? { + val columns = arrayOf(ReactDatabaseSupplier.VALUE_COLUMN) + val selectionArgs = arrayOf(key) + val cursor = dbSupplier.get().query( + ReactDatabaseSupplier.TABLE_CATALYST, + columns, + "${ReactDatabaseSupplier.KEY_COLUMN}=?", + selectionArgs, + null, null, null + ) + try { + return if (!cursor.moveToFirst()) null else cursor.getString(0) + } finally { + cursor.close() + } + } + + private fun setItemImpl(key: String, value: String): Boolean { + val contentValues = android.content.ContentValues() + contentValues.put(ReactDatabaseSupplier.KEY_COLUMN, key) + contentValues.put(ReactDatabaseSupplier.VALUE_COLUMN, value) + val inserted = dbSupplier.get().insertWithOnConflict( + ReactDatabaseSupplier.TABLE_CATALYST, + null, + contentValues, + android.database.sqlite.SQLiteDatabase.CONFLICT_REPLACE + ) + return inserted != -1L + } + + private fun mergeImpl(key: String, value: String): Boolean { + val oldValue = getItemImpl(key) + val newValue: String + if (oldValue == null) { + newValue = value + } else { + val oldJSON = JSONObject(oldValue) + val newJSON = JSONObject(value) + deepMergeInto(oldJSON, newJSON) + newValue = oldJSON.toString() + } + return setItemImpl(key, newValue) + } + + private fun deepMergeInto(oldJSON: JSONObject, newJSON: JSONObject) { + val keys = newJSON.keys() + while (keys.hasNext()) { + val key = keys.next() + val newJSONObject = newJSON.optJSONObject(key) + val oldJSONObject = oldJSON.optJSONObject(key) + if (newJSONObject != null && oldJSONObject != null) { + deepMergeInto(oldJSONObject, newJSONObject) + oldJSON.put(key, oldJSONObject) + } else { + oldJSON.put(key, newJSON.get(key)) + } + } + } +} diff --git a/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStoragePackage.kt b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStoragePackage.kt new file mode 100644 index 00000000..8ae48eee --- /dev/null +++ b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/RNCAsyncStoragePackage.kt @@ -0,0 +1,33 @@ +package com.asyncstorage + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class RNCAsyncStoragePackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == RNCAsyncStorageModule.NAME) { + RNCAsyncStorageModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[RNCAsyncStorageModule.NAME] = ReactModuleInfo( + RNCAsyncStorageModule.NAME, + RNCAsyncStorageModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/ReactDatabaseSupplier.java b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/ReactDatabaseSupplier.java new file mode 100644 index 00000000..6e6032c9 --- /dev/null +++ b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/ReactDatabaseSupplier.java @@ -0,0 +1,140 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.asyncstorage; + +import android.content.Context; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteOpenHelper; +import android.util.Log; +import javax.annotation.Nullable; + +/** + * Database supplier of the database used by react native async storage. + * Ported from upstream @react-native-async-storage/async-storage ReactDatabaseSupplier.java. + */ +public class ReactDatabaseSupplier extends SQLiteOpenHelper { + + public static final String DATABASE_NAME = "RKStorage"; + + private static final int DATABASE_VERSION = 1; + private static final int SLEEP_TIME_MS = 30; + + static final String TABLE_CATALYST = "catalystLocalStorage"; + static final String KEY_COLUMN = "key"; + static final String VALUE_COLUMN = "value"; + + static final String VERSION_TABLE_CREATE = + "CREATE TABLE " + TABLE_CATALYST + " (" + + KEY_COLUMN + " TEXT PRIMARY KEY, " + + VALUE_COLUMN + " TEXT NOT NULL" + + ")"; + + private static @Nullable ReactDatabaseSupplier sReactDatabaseSupplierInstance; + + private Context mContext; + private @Nullable SQLiteDatabase mDb; + private long mMaximumDatabaseSize = 6L * 1024L * 1024L; + + private ReactDatabaseSupplier(Context context) { + super(context, DATABASE_NAME, null, DATABASE_VERSION); + mContext = context; + } + + public static ReactDatabaseSupplier getInstance(Context context) { + if (sReactDatabaseSupplierInstance == null) { + sReactDatabaseSupplierInstance = new ReactDatabaseSupplier(context.getApplicationContext()); + } + return sReactDatabaseSupplierInstance; + } + + @Override + public void onCreate(SQLiteDatabase db) { + db.execSQL(VERSION_TABLE_CREATE); + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion != newVersion) { + deleteDatabase(); + onCreate(db); + } + } + + synchronized boolean ensureDatabase() { + if (mDb != null && mDb.isOpen()) { + return true; + } + SQLiteException lastSQLiteException = null; + for (int tries = 0; tries < 2; tries++) { + try { + if (tries > 0) { + deleteDatabase(); + } + mDb = getWritableDatabase(); + break; + } catch (SQLiteException e) { + lastSQLiteException = e; + } + try { + Thread.sleep(SLEEP_TIME_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + } + if (mDb == null) { + throw lastSQLiteException; + } + mDb.setMaximumSize(mMaximumDatabaseSize); + return true; + } + + public synchronized SQLiteDatabase get() { + ensureDatabase(); + return mDb; + } + + public synchronized void clearAndCloseDatabase() throws RuntimeException { + try { + clear(); + closeDatabase(); + } catch (Exception e) { + if (deleteDatabase()) { + return; + } + throw new RuntimeException("Clearing and deleting database " + DATABASE_NAME + " failed"); + } + } + + synchronized void clear() { + get().delete(TABLE_CATALYST, null, null); + } + + public synchronized void setMaximumSize(long size) { + mMaximumDatabaseSize = size; + if (mDb != null) { + mDb.setMaximumSize(mMaximumDatabaseSize); + } + } + + private synchronized boolean deleteDatabase() { + closeDatabase(); + return mContext.deleteDatabase(DATABASE_NAME); + } + + public synchronized void closeDatabase() { + if (mDb != null && mDb.isOpen()) { + mDb.close(); + mDb = null; + } + } + + public static void deleteInstance() { + sReactDatabaseSupplierInstance = null; + } +} diff --git a/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/SerialExecutor.java b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/SerialExecutor.java new file mode 100644 index 00000000..8dec989c --- /dev/null +++ b/native-modules/react-native-async-storage/android/src/main/java/com/asyncstorage/SerialExecutor.java @@ -0,0 +1,38 @@ +package com.asyncstorage; + +import java.util.ArrayDeque; +import java.util.concurrent.Executor; + +/** + * Ported from upstream @react-native-async-storage/async-storage SerialExecutor.java. + */ +public class SerialExecutor implements Executor { + private final ArrayDeque mTasks = new ArrayDeque(); + private Runnable mActive; + private final Executor executor; + + public SerialExecutor(Executor executor) { + this.executor = executor; + } + + public synchronized void execute(final Runnable r) { + mTasks.offer(new Runnable() { + public void run() { + try { + r.run(); + } finally { + scheduleNext(); + } + } + }); + if (mActive == null) { + scheduleNext(); + } + } + + synchronized void scheduleNext() { + if ((mActive = mTasks.poll()) != null) { + executor.execute(mActive); + } + } +} diff --git a/native-modules/react-native-async-storage/babel.config.js b/native-modules/react-native-async-storage/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-async-storage/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-async-storage/ios/AsyncStorage.h b/native-modules/react-native-async-storage/ios/AsyncStorage.h new file mode 100644 index 00000000..60c02827 --- /dev/null +++ b/native-modules/react-native-async-storage/ios/AsyncStorage.h @@ -0,0 +1,27 @@ +#import + +@interface AsyncStorage : NativeAsyncStorageSpecBase + +- (void)multiGet:(NSArray *)keys + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)multiSet:(NSArray *> *)keyValuePairs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)multiRemove:(NSArray *)keys + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)multiMerge:(NSArray *> *)keyValuePairs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)getAllKeys:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +- (void)clear:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-async-storage/ios/AsyncStorage.mm b/native-modules/react-native-async-storage/ios/AsyncStorage.mm new file mode 100644 index 00000000..4154ff0b --- /dev/null +++ b/native-modules/react-native-async-storage/ios/AsyncStorage.mm @@ -0,0 +1,714 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * Adapted to a proper TurboModule (no RCT_EXPORT_MODULE / RCT_EXPORT_METHOD) + * so it works in both the main and background Hermes runtimes. + */ + +#import "AsyncStorage.h" + +#import +#import +#import + +static NSString *const RCTStorageDirectory = @"RCTAsyncLocalStorage_V1"; +static NSString *const RCTOldStorageDirectory = @"RNCAsyncLocalStorage_V1"; +static NSString *const RCTExpoStorageDirectory = @"RCTAsyncLocalStorage"; +static NSString *const RCTManifestFileName = @"manifest.json"; +static const NSUInteger RCTInlineValueThreshold = 1024; + +#pragma mark - Static helper functions + +static NSDictionary *RCTErrorForKey(NSString *key) +{ + if (![key isKindOfClass:[NSString class]]) { + return RCTMakeAndLogError(@"Invalid key - must be a string. Key: ", key, @{@"key": key}); + } else if (key.length < 1) { + return RCTMakeAndLogError( + @"Invalid key - must be at least one character. Key: ", key, @{@"key": key}); + } else { + return nil; + } +} + +static BOOL RCTAsyncStorageSetExcludedFromBackup(NSString *path, NSNumber *isExcluded) +{ + NSFileManager *fileManager = [[NSFileManager alloc] init]; + + BOOL isDir; + BOOL exists = [fileManager fileExistsAtPath:path isDirectory:&isDir]; + BOOL success = false; + + if (isDir && exists) { + NSURL *pathUrl = [NSURL fileURLWithPath:path]; + NSError *error = nil; + success = [pathUrl setResourceValue:isExcluded + forKey:NSURLIsExcludedFromBackupKey + error:&error]; + + if (!success) { + NSLog(@"Could not exclude AsyncStorage dir from backup %@", error); + } + } + return success; +} + +static void RCTAppendError(NSDictionary *error, NSMutableArray **errors) +{ + if (error && errors) { + if (!*errors) { + *errors = [NSMutableArray new]; + } + [*errors addObject:error]; + } +} + +static NSString *RCTReadFile(NSString *filePath, NSString *key, NSDictionary **errorOut) +{ + if ([[NSFileManager defaultManager] fileExistsAtPath:filePath]) { + NSError *error; + NSStringEncoding encoding; + NSString *entryString = [NSString stringWithContentsOfFile:filePath + usedEncoding:&encoding + error:&error]; + NSDictionary *extraData = @{@"key": RCTNullIfNil(key)}; + + if (error) { + if (errorOut) { + *errorOut = RCTMakeError(@"Failed to read storage file.", error, extraData); + } + return nil; + } + + if (encoding != NSUTF8StringEncoding) { + if (errorOut) { + *errorOut = + RCTMakeError(@"Incorrect encoding of storage file: ", @(encoding), extraData); + } + return nil; + } + return entryString; + } + + return nil; +} + +static NSString *RCTCreateStorageDirectoryPath_deprecated(NSString *storageDir) +{ + NSString *storageDirectoryPath; +#if TARGET_OS_TV + storageDirectoryPath = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; +#else + storageDirectoryPath = + NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; +#endif + storageDirectoryPath = [storageDirectoryPath stringByAppendingPathComponent:storageDir]; + return storageDirectoryPath; +} + +static NSString *RCTCreateStorageDirectoryPath(NSString *storageDir) +{ + NSString *storageDirectoryPath = @""; + +#if TARGET_OS_TV + storageDirectoryPath = + NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; +#else + storageDirectoryPath = + NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES) + .firstObject; + storageDirectoryPath = [storageDirectoryPath + stringByAppendingPathComponent:[[NSBundle mainBundle] bundleIdentifier]]; +#endif + + storageDirectoryPath = [storageDirectoryPath stringByAppendingPathComponent:storageDir]; + + return storageDirectoryPath; +} + +static NSString *RCTGetStorageDirectory() +{ + static NSString *storageDirectory = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ +#if TARGET_OS_TV + RCTLogWarn( + @"Persistent storage is not supported on tvOS, your data may be removed at any point."); +#endif + storageDirectory = RCTCreateStorageDirectoryPath(RCTStorageDirectory); + }); + return storageDirectory; +} + +static NSString *RCTCreateManifestFilePath(NSString *storageDirectory) +{ + return [storageDirectory stringByAppendingPathComponent:RCTManifestFileName]; +} + +static NSString *RCTGetManifestFilePath() +{ + static NSString *manifestFilePath = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + manifestFilePath = RCTCreateManifestFilePath(RCTStorageDirectory); + }); + return manifestFilePath; +} + +// Only merges objects - all other types are just clobbered (including arrays) +static BOOL RCTMergeRecursive(NSMutableDictionary *destination, NSDictionary *source) +{ + BOOL modified = NO; + for (NSString *key in source) { + id sourceValue = source[key]; + id destinationValue = destination[key]; + if ([sourceValue isKindOfClass:[NSDictionary class]]) { + if ([destinationValue isKindOfClass:[NSDictionary class]]) { + if ([destinationValue classForCoder] != [NSMutableDictionary class]) { + destinationValue = [destinationValue mutableCopy]; + } + if (RCTMergeRecursive(destinationValue, sourceValue)) { + destination[key] = destinationValue; + modified = YES; + } + } else { + destination[key] = [sourceValue copy]; + modified = YES; + } + } else if (![source isEqual:destinationValue]) { + destination[key] = [sourceValue copy]; + modified = YES; + } + } + return modified; +} + +static dispatch_queue_t RCTGetMethodQueue() +{ + // We want all instances to share the same queue since they will be reading/writing the same + // files. + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = + dispatch_queue_create("com.facebook.react.AsyncLocalStorageQueue", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +static NSCache *RCTGetCache() +{ + // We want all instances to share the same cache since they will be reading/writing the same + // files. + static NSCache *cache; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + cache = [NSCache new]; + cache.totalCostLimit = 2 * 1024 * 1024; // 2MB + +#if !TARGET_OS_OSX + // Clear cache in the event of a memory warning + [[NSNotificationCenter defaultCenter] + addObserverForName:UIApplicationDidReceiveMemoryWarningNotification + object:nil + queue:nil + usingBlock:^(__unused NSNotification *note) { + [cache removeAllObjects]; + }]; +#endif // !TARGET_OS_OSX + }); + return cache; +} + +static BOOL RCTHasCreatedStorageDirectory = NO; + +static NSDictionary *RCTDeleteStorageDirectory() +{ + NSError *error; + [[NSFileManager defaultManager] removeItemAtPath:RCTGetStorageDirectory() error:&error]; + RCTHasCreatedStorageDirectory = NO; + if (error && error.code != NSFileNoSuchFileError) { + return RCTMakeError(@"Failed to delete storage directory.", error, nil); + } + return nil; +} + +static NSDate *RCTManifestModificationDate(NSString *manifestFilePath) +{ + NSDictionary *attributes = + [[NSFileManager defaultManager] attributesOfItemAtPath:manifestFilePath error:nil]; + return [attributes fileModificationDate]; +} + +static void RCTStorageDirectoryMigrationLogError(NSString *reason, NSError *error) +{ + RCTLogWarn(@"%@: %@", reason, error ? error.description : @""); +} + +static void RCTStorageDirectoryCleanupOld(NSString *oldDirectoryPath) +{ + NSError *error; + if (![[NSFileManager defaultManager] removeItemAtPath:oldDirectoryPath error:&error]) { + RCTStorageDirectoryMigrationLogError( + @"Failed to remove old storage directory during migration", error); + } +} + +static void _createStorageDirectory(NSString *storageDirectory, NSError **error) +{ + [[NSFileManager defaultManager] createDirectoryAtPath:storageDirectory + withIntermediateDirectories:YES + attributes:nil + error:error]; +} + +static void RCTStorageDirectoryMigrate(NSString *oldDirectoryPath, + NSString *newDirectoryPath, + BOOL shouldCleanupOldDirectory) +{ + NSError *error; + if (![[NSFileManager defaultManager] copyItemAtPath:oldDirectoryPath + toPath:newDirectoryPath + error:&error]) { + if (error != nil && error.code == 4 && + [newDirectoryPath isEqualToString:RCTGetStorageDirectory()]) { + error = nil; + _createStorageDirectory(RCTCreateStorageDirectoryPath(@""), &error); + if (error == nil) { + RCTStorageDirectoryMigrate( + oldDirectoryPath, newDirectoryPath, shouldCleanupOldDirectory); + } else { + RCTStorageDirectoryMigrationLogError( + @"Failed to create storage directory during migration.", error); + } + } else { + RCTStorageDirectoryMigrationLogError( + @"Failed to copy old storage directory to new storage directory location during " + @"migration", + error); + } + } else if (shouldCleanupOldDirectory) { + RCTStorageDirectoryCleanupOld(oldDirectoryPath); + } +} + +static NSString *RCTGetStoragePathForMigration() +{ + BOOL isDir; + NSString *oldStoragePath = RCTCreateStorageDirectoryPath_deprecated(RCTOldStorageDirectory); + NSString *expoStoragePath = RCTCreateStorageDirectoryPath_deprecated(RCTExpoStorageDirectory); + NSFileManager *fileManager = [NSFileManager defaultManager]; + BOOL oldStorageDirectoryExists = + [fileManager fileExistsAtPath:oldStoragePath isDirectory:&isDir] && isDir; + BOOL expoStorageDirectoryExists = + [fileManager fileExistsAtPath:expoStoragePath isDirectory:&isDir] && isDir; + + if (oldStorageDirectoryExists && expoStorageDirectoryExists) { + if ([RCTManifestModificationDate(RCTCreateManifestFilePath(oldStoragePath)) + compare:RCTManifestModificationDate(RCTCreateManifestFilePath(expoStoragePath))] == + NSOrderedDescending) { + RCTStorageDirectoryCleanupOld(expoStoragePath); + return oldStoragePath; + } else { + RCTStorageDirectoryCleanupOld(oldStoragePath); + return expoStoragePath; + } + } else if (oldStorageDirectoryExists) { + return oldStoragePath; + } else if (expoStorageDirectoryExists) { + return expoStoragePath; + } else { + return nil; + } +} + +static void RCTStorageDirectoryMigrationCheck(NSString *fromStorageDirectory, + NSString *toStorageDirectory, + BOOL shouldCleanupOldDirectoryAndOverwriteNewDirectory) +{ + NSError *error; + BOOL isDir; + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:fromStorageDirectory isDirectory:&isDir] && isDir) { + if ([fileManager fileExistsAtPath:toStorageDirectory]) { + if ([RCTManifestModificationDate(RCTCreateManifestFilePath(toStorageDirectory)) + compare:RCTManifestModificationDate( + RCTCreateManifestFilePath(fromStorageDirectory))] == 1) { + if (shouldCleanupOldDirectoryAndOverwriteNewDirectory) { + RCTStorageDirectoryCleanupOld(fromStorageDirectory); + } + } else if (shouldCleanupOldDirectoryAndOverwriteNewDirectory) { + if (![fileManager removeItemAtPath:toStorageDirectory error:&error]) { + RCTStorageDirectoryMigrationLogError( + @"Failed to remove new storage directory during migration", error); + } else { + RCTStorageDirectoryMigrate(fromStorageDirectory, + toStorageDirectory, + shouldCleanupOldDirectoryAndOverwriteNewDirectory); + } + } + } else { + RCTStorageDirectoryMigrate(fromStorageDirectory, + toStorageDirectory, + shouldCleanupOldDirectoryAndOverwriteNewDirectory); + } + } +} + +#pragma mark - AsyncStorage + +@implementation AsyncStorage { + BOOL _haveSetup; + // The manifest is a dictionary of all keys with small values inlined. Null values indicate + // values that are stored in separate files (as opposed to nil values which don't exist). The + // manifest is read off disk at startup, and written to disk after all mutations. + NSMutableDictionary *_manifest; +} + ++ (NSString *)moduleName +{ + return @"RNCAsyncStorage"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + +- (instancetype)init +{ + if (!(self = [super init])) { + return nil; + } + + NSString *oldStoragePath = RCTGetStoragePathForMigration(); + if (oldStoragePath != nil) { + RCTStorageDirectoryMigrationCheck( + oldStoragePath, RCTCreateStorageDirectoryPath_deprecated(RCTStorageDirectory), YES); + } + + RCTStorageDirectoryMigrationCheck(RCTCreateStorageDirectoryPath_deprecated(RCTStorageDirectory), + RCTCreateStorageDirectoryPath(RCTStorageDirectory), + NO); + + return self; +} + +- (NSString *)_filePathForKey:(NSString *)key +{ + NSString *safeFileName = RCTMD5Hash(key); + return [RCTGetStorageDirectory() stringByAppendingPathComponent:safeFileName]; +} + +- (NSDictionary *)_ensureSetup +{ + NSError *error = nil; + if (!RCTHasCreatedStorageDirectory) { + _createStorageDirectory(RCTGetStorageDirectory(), &error); + if (error) { + return RCTMakeError(@"Failed to create storage directory.", error, nil); + } + RCTHasCreatedStorageDirectory = YES; + } + + if (!_haveSetup) { + NSNumber *isExcludedFromBackup = + [[NSBundle mainBundle] objectForInfoDictionaryKey:@"RCTAsyncStorageExcludeFromBackup"]; + if (isExcludedFromBackup == nil) { + isExcludedFromBackup = @YES; + } + RCTAsyncStorageSetExcludedFromBackup(RCTCreateStorageDirectoryPath(RCTStorageDirectory), + isExcludedFromBackup); + + NSDictionary *errorOut = nil; + NSString *serialized = RCTReadFile(RCTCreateStorageDirectoryPath(RCTGetManifestFilePath()), + RCTManifestFileName, + &errorOut); + if (!serialized) { + if (errorOut) { + RCTLogError( + @"Could not open the existing manifest, perhaps data protection is " + @"enabled?\n\n%@", + errorOut); + return errorOut; + } else { + _manifest = [NSMutableDictionary new]; + } + } else { + _manifest = RCTJSONParseMutable(serialized, &error); + if (!_manifest) { + RCTLogError(@"Failed to parse manifest - creating a new one.\n\n%@", error); + _manifest = [NSMutableDictionary new]; + } + } + _haveSetup = YES; + } + + return nil; +} + +- (NSDictionary *)_writeManifest:(NSMutableArray *__autoreleasing *)errors +{ + NSError *error; + NSString *serialized = RCTJSONStringify(_manifest, &error); + [serialized writeToFile:RCTCreateStorageDirectoryPath(RCTGetManifestFilePath()) + atomically:YES + encoding:NSUTF8StringEncoding + error:&error]; + NSDictionary *errorOut; + if (error) { + errorOut = RCTMakeError(@"Failed to write manifest file.", error, nil); + RCTAppendError(errorOut, errors); + } + return errorOut; +} + +- (NSString *)_getValueForKey:(NSString *)key errorOut:(NSDictionary *__autoreleasing *)errorOut +{ + NSString *value = _manifest[key]; + if (value == (id)kCFNull) { + value = [RCTGetCache() objectForKey:key]; + if (!value) { + NSString *filePath = [self _filePathForKey:key]; + value = RCTReadFile(filePath, key, errorOut); + if (value) { + [RCTGetCache() setObject:value forKey:key cost:value.length]; + } else { + [_manifest removeObjectForKey:key]; + } + } + } + return value; +} + +- (NSDictionary *)_writeEntry:(NSArray *)entry changedManifest:(BOOL *)changedManifest +{ + if (entry.count != 2) { + return RCTMakeAndLogError( + @"Entries must be arrays of the form [key: string, value: string], got: ", entry, nil); + } + NSString *key = entry[0]; + NSDictionary *errorOut = RCTErrorForKey(key); + if (errorOut) { + return errorOut; + } + NSString *value = entry[1]; + NSString *filePath = [self _filePathForKey:key]; + NSError *error; + if (value.length <= RCTInlineValueThreshold) { + if (_manifest[key] == (id)kCFNull) { + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; + [RCTGetCache() removeObjectForKey:key]; + } + *changedManifest = YES; + _manifest[key] = value; + return nil; + } + [value writeToFile:filePath atomically:YES encoding:NSUTF8StringEncoding error:&error]; + [RCTGetCache() setObject:value forKey:key cost:value.length]; + if (error) { + errorOut = RCTMakeError(@"Failed to write value.", error, @{@"key": key}); + } else if (_manifest[key] != (id)kCFNull) { + *changedManifest = YES; + _manifest[key] = (id)kCFNull; + } + return errorOut; +} + +#pragma mark - TurboModule methods + +- (void)multiGet:(NSArray *)keys + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + NSDictionary *ensureSetupError = [self _ensureSetup]; + if (ensureSetupError) { + reject(@"ASYNC_STORAGE_ERROR", + ensureSetupError[@"message"] ?: @"Storage setup failed", + nil); + return; + } + + NSMutableArray *result = [NSMutableArray arrayWithCapacity:keys.count]; + for (NSString *key in keys) { + NSDictionary *keyError = RCTErrorForKey(key); + if (keyError) { + [result addObject:@[RCTNullIfNil(key), (id)kCFNull]]; + } else { + NSDictionary *errorOut = nil; + NSString *value = [self _getValueForKey:key errorOut:&errorOut]; + [result addObject:@[key, RCTNullIfNil(value)]]; + } + } + resolve(result); + }); +} + +- (void)multiSet:(NSArray *> *)keyValuePairs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + NSDictionary *ensureSetupError = [self _ensureSetup]; + if (ensureSetupError) { + reject(@"ASYNC_STORAGE_ERROR", + ensureSetupError[@"message"] ?: @"Storage setup failed", + nil); + return; + } + + BOOL changedManifest = NO; + NSMutableArray *errors; + for (NSArray *entry in keyValuePairs) { + NSDictionary *keyError = [self _writeEntry:entry changedManifest:&changedManifest]; + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + if (errors.count > 0) { + reject(@"ASYNC_STORAGE_ERROR", + @"One or more keys failed to set", + nil); + } else { + resolve(nil); + } + }); +} + +- (void)multiRemove:(NSArray *)keys + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + NSDictionary *ensureSetupError = [self _ensureSetup]; + if (ensureSetupError) { + reject(@"ASYNC_STORAGE_ERROR", + ensureSetupError[@"message"] ?: @"Storage setup failed", + nil); + return; + } + + NSMutableArray *errors; + BOOL changedManifest = NO; + for (NSString *key in keys) { + NSDictionary *keyError = RCTErrorForKey(key); + if (!keyError) { + if (self->_manifest[key] == (id)kCFNull) { + NSString *filePath = [self _filePathForKey:key]; + [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil]; + [RCTGetCache() removeObjectForKey:key]; + } + if (self->_manifest[key]) { + changedManifest = YES; + [self->_manifest removeObjectForKey:key]; + } + } + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + if (errors.count > 0) { + reject(@"ASYNC_STORAGE_ERROR", + @"One or more keys failed to remove", + nil); + } else { + resolve(nil); + } + }); +} + +- (void)multiMerge:(NSArray *> *)keyValuePairs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + NSDictionary *ensureSetupError = [self _ensureSetup]; + if (ensureSetupError) { + reject(@"ASYNC_STORAGE_ERROR", + ensureSetupError[@"message"] ?: @"Storage setup failed", + nil); + return; + } + + BOOL changedManifest = NO; + NSMutableArray *errors; + for (__strong NSArray *entry in keyValuePairs) { + NSDictionary *keyError; + NSString *value = [self _getValueForKey:entry[0] errorOut:&keyError]; + if (!keyError) { + if (value) { + NSError *jsonError; + NSMutableDictionary *mergedVal = RCTJSONParseMutable(value, &jsonError); + NSDictionary *mergingValue = RCTJSONParse(entry[1], &jsonError); + if (!mergingValue.count || RCTMergeRecursive(mergedVal, mergingValue)) { + entry = @[entry[0], RCTNullIfNil(RCTJSONStringify(mergedVal, NULL))]; + } + if (jsonError) { + keyError = RCTJSErrorFromNSError(jsonError); + } + } + if (!keyError) { + keyError = [self _writeEntry:entry changedManifest:&changedManifest]; + } + } + RCTAppendError(keyError, &errors); + } + if (changedManifest) { + [self _writeManifest:&errors]; + } + if (errors.count > 0) { + reject(@"ASYNC_STORAGE_ERROR", + @"One or more keys failed to merge", + nil); + } else { + resolve(nil); + } + }); +} + +- (void)getAllKeys:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + NSDictionary *ensureSetupError = [self _ensureSetup]; + if (ensureSetupError) { + reject(@"ASYNC_STORAGE_ERROR", + ensureSetupError[@"message"] ?: @"Storage setup failed", + nil); + return; + } + resolve(self->_manifest.allKeys); + }); +} + +- (void)clear:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(RCTGetMethodQueue(), ^{ + [self->_manifest removeAllObjects]; + [RCTGetCache() removeAllObjects]; + NSDictionary *error = RCTDeleteStorageDirectory(); + if (error) { + reject(@"ASYNC_STORAGE_ERROR", + error[@"message"] ?: @"Failed to clear storage", + nil); + } else { + resolve(nil); + } + }); +} + +@end diff --git a/native-modules/react-native-async-storage/package.json b/native-modules/react-native-async-storage/package.json new file mode 100644 index 00000000..8d8193a3 --- /dev/null +++ b/native-modules/react-native-async-storage/package.json @@ -0,0 +1,168 @@ +{ + "name": "@onekeyfe/react-native-async-storage", + "version": "3.0.18", + "description": "react-native-async-storage", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./lib/typescript/types": { + "types": "./lib/typescript/src/types.d.ts" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "async-storage" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-async-storage.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-async-storage/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-async-storage#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "dependencies": { + "merge-options": "^3.0.4" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "AsyncStorageSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.asyncstorage" + }, + "ios": { + "modulesProvider": { + "RNCAsyncStorage": "AsyncStorage" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-async-storage/src/NativeAsyncStorage.ts b/native-modules/react-native-async-storage/src/NativeAsyncStorage.ts new file mode 100644 index 00000000..3c43b075 --- /dev/null +++ b/native-modules/react-native-async-storage/src/NativeAsyncStorage.ts @@ -0,0 +1,13 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + multiGet(keys: string[]): Promise<[string, string | null][]>; + multiSet(keyValuePairs: [string, string][]): Promise; + multiRemove(keys: string[]): Promise; + multiMerge(keyValuePairs: [string, string][]): Promise; + getAllKeys(): Promise; + clear(): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNCAsyncStorage'); diff --git a/native-modules/react-native-async-storage/src/NativeAsyncStorage.web.ts b/native-modules/react-native-async-storage/src/NativeAsyncStorage.web.ts new file mode 100644 index 00000000..93d42a9e --- /dev/null +++ b/native-modules/react-native-async-storage/src/NativeAsyncStorage.web.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) Nicolas Gallagher. + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import mergeOptions from 'merge-options'; +import type { + AsyncStorageStatic, + MultiCallback, + MultiGetCallback, +} from './types'; + +// eslint-disable-next-line @typescript-eslint/ban-types +type OnMultiResult = Function; +// eslint-disable-next-line @typescript-eslint/ban-types +type OnResult = Function; + +const merge = mergeOptions.bind({ + concatArrays: true, + ignoreUndefined: true, +}); + +function mergeLocalStorageItem(key: string, value: string) { + const oldValue = window.localStorage.getItem(key); + if (oldValue) { + const oldObject = JSON.parse(oldValue); + const newObject = JSON.parse(value); + const nextValue = JSON.stringify(merge(oldObject, newObject)); + window.localStorage.setItem(key, nextValue); + } else { + window.localStorage.setItem(key, value); + } +} + +function createPromise( + getValue: () => Result, + callback?: Callback +): Promise { + return new Promise((resolve, reject) => { + try { + const value = getValue(); + callback?.(null, value); + resolve(value); + } catch (err) { + callback?.(err); + reject(err); + } + }); +} + +function createPromiseAll< + ReturnType, + Result, + ResultProcessor extends OnMultiResult +>( + promises: Promise[], + callback?: MultiCallback | MultiGetCallback, + processResult?: ResultProcessor +): Promise { + return Promise.all(promises).then( + (result) => { + const value = processResult?.(result) ?? null; + callback?.(null, value); + return Promise.resolve(value); + }, + (errors) => { + callback?.(errors); + return Promise.reject(errors); + } + ); +} + +const AsyncStorage: AsyncStorageStatic = { + /** + * Fetches `key` value. + */ + getItem: (key, callback) => { + return createPromise(() => window.localStorage.getItem(key), callback); + }, + + /** + * Sets `value` for `key`. + */ + setItem: (key, value, callback) => { + return createPromise( + () => window.localStorage.setItem(key, value), + callback + ); + }, + + /** + * Removes a `key` + */ + removeItem: (key, callback) => { + return createPromise(() => window.localStorage.removeItem(key), callback); + }, + + /** + * Merges existing value with input value, assuming they are stringified JSON. + */ + mergeItem: (key, value, callback) => { + return createPromise(() => mergeLocalStorageItem(key, value), callback); + }, + + /** + * Erases *all* AsyncStorage for the domain. + */ + clear: (callback) => { + return createPromise(() => window.localStorage.clear(), callback); + }, + + /** + * Gets *all* keys known to the app, for all callers, libraries, etc. + */ + getAllKeys: (callback) => { + return createPromise(() => { + const numberOfKeys = window.localStorage.length; + const keys: string[] = []; + for (let i = 0; i < numberOfKeys; i += 1) { + const key = window.localStorage.key(i) || ''; + keys.push(key); + } + return keys; + }, callback); + }, + + /** + * (stub) Flushes any pending requests using a single batch call to get the data. + */ + flushGetRequests: () => undefined, + + /** + * multiGet resolves to an array of key-value pair arrays that matches the + * input format of multiSet. + * + * multiGet(['k1', 'k2']) -> [['k1', 'val1'], ['k2', 'val2']] + */ + multiGet: (keys, callback) => { + const promises = keys.map((key) => AsyncStorage.getItem(key)); + const processResult = (result: string[]) => + result.map((value, i) => [keys[i], value]); + return createPromiseAll(promises, callback, processResult); + }, + + /** + * Takes an array of key-value array pairs. + * multiSet([['k1', 'val1'], ['k2', 'val2']]) + */ + multiSet: (keyValuePairs, callback) => { + const promises = keyValuePairs.map((item) => + AsyncStorage.setItem(item[0], item[1]) + ); + return createPromiseAll(promises, callback); + }, + + /** + * Delete all the keys in the `keys` array. + */ + multiRemove: (keys, callback) => { + const promises = keys.map((key) => AsyncStorage.removeItem(key)); + return createPromiseAll(promises, callback); + }, + + /** + * Takes an array of key-value array pairs and merges them with existing + * values, assuming they are stringified JSON. + * + * multiMerge([['k1', 'val1'], ['k2', 'val2']]) + */ + multiMerge: (keyValuePairs, callback) => { + const promises = keyValuePairs.map((item) => + AsyncStorage.mergeItem(item[0], item[1]) + ); + return createPromiseAll(promises, callback); + }, +}; + +export default AsyncStorage; diff --git a/native-modules/react-native-async-storage/src/index.tsx b/native-modules/react-native-async-storage/src/index.tsx new file mode 100644 index 00000000..2bba274f --- /dev/null +++ b/native-modules/react-native-async-storage/src/index.tsx @@ -0,0 +1,172 @@ +import NativeModule from './NativeAsyncStorage'; +import type { AsyncStorageStatic } from './types'; + +function createAsyncStorage(): AsyncStorageStatic { + const getItem: AsyncStorageStatic['getItem'] = async (key, callback) => { + try { + const result = await NativeModule.multiGet([key]); + const value = result?.[0]?.[1] ?? null; + callback?.(null, value); + return value; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const setItem: AsyncStorageStatic['setItem'] = async ( + key, + value, + callback + ) => { + try { + await NativeModule.multiSet([[key, value]]); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const removeItem: AsyncStorageStatic['removeItem'] = async ( + key, + callback + ) => { + try { + await NativeModule.multiRemove([key]); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const mergeItem: AsyncStorageStatic['mergeItem'] = async ( + key, + value, + callback + ) => { + try { + await NativeModule.multiMerge([[key, value]]); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const clear: AsyncStorageStatic['clear'] = async (callback) => { + try { + await NativeModule.clear(); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const getAllKeys: AsyncStorageStatic['getAllKeys'] = async (callback) => { + try { + const keys = await NativeModule.getAllKeys(); + callback?.(null, keys); + return keys; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.(error); + throw error; + } + }; + + const flushGetRequests: AsyncStorageStatic['flushGetRequests'] = () => { + // No-op: legacy batching API, not needed with TurboModules + }; + + const multiGet: AsyncStorageStatic['multiGet'] = async (keys, callback) => { + try { + const result = await NativeModule.multiGet([...keys]); + callback?.(null, result); + return result; + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.([error]); + throw error; + } + }; + + const multiSet: AsyncStorageStatic['multiSet'] = async ( + keyValuePairs, + callback + ) => { + try { + const mutablePairs = keyValuePairs.map( + ([k, v]) => [k, v] as [string, string] + ); + await NativeModule.multiSet(mutablePairs); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.([error]); + throw error; + } + }; + + const multiRemove: AsyncStorageStatic['multiRemove'] = async ( + keys, + callback + ) => { + try { + await NativeModule.multiRemove([...keys]); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.([error]); + throw error; + } + }; + + const multiMerge: AsyncStorageStatic['multiMerge'] = async ( + keyValuePairs, + callback + ) => { + try { + await NativeModule.multiMerge(keyValuePairs); + callback?.(null); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + callback?.([error]); + throw error; + } + }; + + return { + getItem, + setItem, + removeItem, + mergeItem, + clear, + getAllKeys, + flushGetRequests, + multiGet, + multiSet, + multiRemove, + multiMerge, + }; +} + +const AsyncStorage = createAsyncStorage(); + +export default AsyncStorage; + +export type { + AsyncStorageStatic, + Callback, + CallbackWithResult, + KeyValuePair, + MultiCallback, + MultiGetCallback, +} from './types'; diff --git a/native-modules/react-native-async-storage/src/merge-options.d.ts b/native-modules/react-native-async-storage/src/merge-options.d.ts new file mode 100644 index 00000000..1fd2b366 --- /dev/null +++ b/native-modules/react-native-async-storage/src/merge-options.d.ts @@ -0,0 +1,4 @@ +declare module 'merge-options' { + function mergeOptions(...args: T[]): T; + export default mergeOptions; +} diff --git a/native-modules/react-native-async-storage/src/types.ts b/native-modules/react-native-async-storage/src/types.ts new file mode 100644 index 00000000..99bdbc92 --- /dev/null +++ b/native-modules/react-native-async-storage/src/types.ts @@ -0,0 +1,54 @@ +// Types aligned with the original @react-native-async-storage/async-storage + +export type Callback = (error?: Error | null) => void; + +export type CallbackWithResult = ( + error?: Error | null, + result?: T | null +) => void; + +export type KeyValuePair = [string, string | null]; + +export type MultiCallback = ( + errors?: readonly (Error | null)[] | null +) => void; + +export type MultiGetCallback = ( + errors?: readonly (Error | null)[] | null, + result?: readonly KeyValuePair[] +) => void; + +export type AsyncStorageStatic = { + getItem: ( + key: string, + callback?: CallbackWithResult + ) => Promise; + setItem: ( + key: string, + value: string, + callback?: Callback + ) => Promise; + removeItem: (key: string, callback?: Callback) => Promise; + mergeItem: (key: string, value: string, callback?: Callback) => Promise; + clear: (callback?: Callback) => Promise; + getAllKeys: ( + callback?: CallbackWithResult + ) => Promise; + flushGetRequests: () => void; + multiGet: ( + keys: readonly string[], + callback?: MultiGetCallback + ) => Promise; + multiSet: ( + keyValuePairs: ReadonlyArray, + callback?: MultiCallback + ) => Promise; + multiRemove: ( + keys: readonly string[], + callback?: MultiCallback + ) => Promise; + multiMerge: ( + keyValuePairs: [string, string][], + callback?: MultiCallback + ) => Promise; +}; diff --git a/native-modules/react-native-async-storage/tsconfig.build.json b/native-modules/react-native-async-storage/tsconfig.build.json new file mode 100644 index 00000000..34699441 --- /dev/null +++ b/native-modules/react-native-async-storage/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["lib"] +} diff --git a/native-modules/react-native-async-storage/tsconfig.json b/native-modules/react-native-async-storage/tsconfig.json new file mode 100644 index 00000000..098a5013 --- /dev/null +++ b/native-modules/react-native-async-storage/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "@onekeyfe/react-native-async-storage": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext", "DOM"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-async-storage/turbo.json b/native-modules/react-native-async-storage/turbo.json new file mode 100644 index 00000000..2345e23a --- /dev/null +++ b/native-modules/react-native-async-storage/turbo.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-background-thread/BackgroundThread.podspec b/native-modules/react-native-background-thread/BackgroundThread.podspec index c3cf0a71..9d87aa5f 100644 --- a/native-modules/react-native-background-thread/BackgroundThread.podspec +++ b/native-modules/react-native-background-thread/BackgroundThread.podspec @@ -13,8 +13,10 @@ Pod::Spec.new do |s| s.platforms = { :ios => min_ios_version_supported } s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-background-thread.git", :tag => "#{s.version}" } - s.source_files = "ios/**/*.{h,m,mm,swift,cpp}" - s.public_header_files = "ios/**/*.h" + s.source_files = "ios/**/*.{h,m,mm,swift,cpp}", "cpp/**/*.{h,cpp}" + s.public_header_files = "ios/BackgroundThreadManager.h", "ios/BTLogger.h" + s.pod_target_xcconfig = { 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/cpp"', 'DEFINES_MODULE' => 'YES' } + s.user_target_xcconfig = { 'SWIFT_INCLUDE_PATHS' => '"$(PODS_ROOT)/Headers/Public/BackgroundThread"' } s.dependency 'ReactNativeNativeLogger' diff --git a/native-modules/react-native-background-thread/android/CMakeLists.txt b/native-modules/react-native-background-thread/android/CMakeLists.txt new file mode 100644 index 00000000..fe5212ea --- /dev/null +++ b/native-modules/react-native-background-thread/android/CMakeLists.txt @@ -0,0 +1,29 @@ +cmake_minimum_required(VERSION 3.13) +project(background_thread) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_VERBOSE_MAKEFILE ON) + +find_package(fbjni REQUIRED CONFIG) +find_package(ReactAndroid REQUIRED CONFIG) + +add_library(${PROJECT_NAME} SHARED + src/main/cpp/cpp-adapter.cpp + ../cpp/SharedStore.cpp + ../cpp/SharedRPC.cpp +) + +target_include_directories(${PROJECT_NAME} PRIVATE + src/main/cpp + ../cpp +) + +find_library(LOG_LIB log) + +target_link_libraries(${PROJECT_NAME} + ${LOG_LIB} + fbjni::fbjni + ReactAndroid::jsi + ReactAndroid::reactnative + android +) diff --git a/native-modules/react-native-background-thread/android/build.gradle b/native-modules/react-native-background-thread/android/build.gradle index 62bad2b3..62541acd 100644 --- a/native-modules/react-native-background-thread/android/build.gradle +++ b/native-modules/react-native-background-thread/android/build.gradle @@ -25,6 +25,11 @@ def getExtOrIntegerDefault(name) { return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["BackgroundThread_" + name]).toInteger() } +def reactNativeArchitectures() { + def value = rootProject.getProperties().get("reactNativeArchitectures") + return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] +} + android { namespace "com.backgroundthread" @@ -33,10 +38,47 @@ android { defaultConfig { minSdkVersion getExtOrIntegerDefault("minSdkVersion") targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + + externalNativeBuild { + cmake { + cppFlags "-frtti -fexceptions -Wall -fstack-protector-all" + arguments "-DANDROID_STL=c++_shared", + "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON" + abiFilters(*reactNativeArchitectures()) + } + } + } + + externalNativeBuild { + cmake { + path "CMakeLists.txt" + } } buildFeatures { buildConfig true + prefab true + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] } buildTypes { diff --git a/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp b/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp new file mode 100644 index 00000000..a750f7ac --- /dev/null +++ b/native-modules/react-native-background-thread/android/src/main/cpp/cpp-adapter.cpp @@ -0,0 +1,597 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "SharedStore.h" +#include "SharedRPC.h" + +#define LOG_TAG "BackgroundThread" +#define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) +#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) + +namespace jsi = facebook::jsi; + +static JavaVM *gJavaVM = nullptr; + +extern "C" JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *) { + gJavaVM = vm; + return JNI_VERSION_1_6; +} + +static JNIEnv *getJNIEnv() { + JNIEnv *env = nullptr; + if (gJavaVM) { + gJavaVM->AttachCurrentThread(&env, nullptr); + } + return env; +} + +// Stub a JSI function on an object (replaces it with a no-op). +static void stubJsiFunction(jsi::Runtime &runtime, jsi::Object &object, const char *name) { + object.setProperty( + runtime, + name, + jsi::Function::createFromHostFunction( + runtime, jsi::PropNameID::forUtf8(runtime, name), 1, + [](auto &, const auto &, const auto *, size_t) { + return jsi::Value::undefined(); + })); +} + +static void invokeOptionalGlobalFunction(jsi::Runtime &runtime, const char *name) { + try { + auto fnValue = runtime.global().getProperty(runtime, name); + if (!fnValue.isObject() || !fnValue.asObject(runtime).isFunction(runtime)) { + return; + } + + auto fn = fnValue.asObject(runtime).asFunction(runtime); + fn.call(runtime); + } catch (const jsi::JSError &e) { + LOGE("JSError calling global function %s: %s", name, e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("Error calling global function %s: %s", name, e.what()); + } +} + +// ── Pending work map for cross-runtime executor ─────────────────────── +static std::mutex gWorkMutex; +static std::unordered_map> gPendingWork; +static int64_t gNextWorkId = 0; + +// Called from Kotlin after runOnJSQueueThread dispatches to the correct thread. +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeExecuteWork( + JNIEnv *env, jobject thiz, jlong runtimePtr, jlong workId) { + LOGI("nativeExecuteWork: runtimePtr=%ld, workId=%ld", (long)runtimePtr, (long)workId); + auto *rt = reinterpret_cast(runtimePtr); + if (!rt) return; + + std::function work; + { + std::lock_guard lock(gWorkMutex); + auto it = gPendingWork.find(workId); + if (it == gPendingWork.end()) return; + work = std::move(it->second); + gPendingWork.erase(it); + } + try { + work(*rt); + } catch (const jsi::JSError &e) { + LOGE("JSError in nativeExecuteWork: %s", e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("Error in nativeExecuteWork: %s", e.what()); + } + + // CRITICAL: Drain the Hermes microtask queue. React Native 0.74+ configures + // Hermes with an explicit microtask queue, which must be manually drained + // after each JS execution. Without this, Promise.then() / async-await + // continuations (including already-resolved promises) are never executed, + // causing all awaits to hang forever in the background runtime. + try { + rt->drainMicrotasks(); + } catch (const jsi::JSError &e) { + LOGE("JSError draining microtasks: %s", e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("Error draining microtasks: %s", e.what()); + } +} + +// ── Timer support for background runtime ────────────────────────────── +// The background Hermes runtime does NOT have working setTimeout/setInterval +// out of the box (RN's timer module only wires into the main runtime). We +// install our own JSI-level setTimeout/setInterval/clearTimeout/clearInterval +// backed by a single C++ worker thread that dispatches callbacks back to the +// background JS queue via the same executor used by SharedRPC. + +struct TimerEntry { + std::shared_ptr callback; + long long fireAtMs; // Absolute time in ms when the timer should fire. + long long intervalMs; // 0 if one-shot, >0 if setInterval period. + bool cancelled; +}; + +static std::mutex gTimerMutex; +static std::condition_variable gTimerCv; +static std::unordered_map gTimers; +static std::atomic gNextTimerId{1}; +static std::atomic gTimerWorkerStarted{false}; +static std::atomic gTimerWorkerStop{false}; +static RPCRuntimeExecutor gBgTimerExecutor; + +static long long nowMs() { + using namespace std::chrono; + return duration_cast( + steady_clock::now().time_since_epoch()) + .count(); +} + +// Called on the bg JS thread. Executes the callback only; the worker has +// already erased (one-shot) or rescheduled (interval) the timer under lock. +static void fireTimerOnJsThread( + int64_t timerId, + std::shared_ptr cb, + jsi::Runtime &rt) { + if (!cb) return; + try { + cb->call(rt); + } catch (const jsi::JSError &e) { + LOGE("Timer %lld callback JSError: %s", (long long)timerId, + e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("Timer %lld callback error: %s", (long long)timerId, e.what()); + } +} + +static void timerWorkerLoop() { + while (!gTimerWorkerStop.load()) { + // Snapshot of timers that should be dispatched this iteration and + // their callbacks. Captured under the lock; callbacks are invoked on + // the JS thread (not here). + std::vector>> toFire; + { + std::unique_lock lock(gTimerMutex); + if (gTimers.empty()) { + gTimerCv.wait(lock, [] { + return gTimerWorkerStop.load() || !gTimers.empty(); + }); + if (gTimerWorkerStop.load()) return; + continue; + } + + // Find the earliest fireAt among non-cancelled timers. + long long earliest = LLONG_MAX; + for (auto &kv : gTimers) { + if (!kv.second.cancelled && kv.second.fireAtMs < earliest) { + earliest = kv.second.fireAtMs; + } + } + long long now = nowMs(); + if (earliest == LLONG_MAX) { + // Only cancelled timers remain; clean them up. + for (auto it = gTimers.begin(); it != gTimers.end();) { + if (it->second.cancelled) it = gTimers.erase(it); + else ++it; + } + continue; + } + if (earliest > now) { + gTimerCv.wait_for( + lock, std::chrono::milliseconds(earliest - now)); + continue; + } + + // Collect ready timers AND either erase (one-shot) or reschedule + // (interval) them RIGHT HERE under the lock. This is critical: + // if we wait to erase in fireTimerOnJsThread, the next worker + // iteration would immediately find the same timers still + // in-queue and re-dispatch them, causing an infinite flood of + // `scheduleOnJSThread` calls. + for (auto it = gTimers.begin(); it != gTimers.end();) { + if (it->second.cancelled) { + it = gTimers.erase(it); + continue; + } + if (it->second.fireAtMs <= now) { + toFire.emplace_back(it->first, it->second.callback); + if (it->second.intervalMs > 0) { + // Reschedule interval. Use `now + intervalMs` rather + // than `fireAtMs + intervalMs` so a slow fire path + // cannot produce an infinite backlog. + it->second.fireAtMs = now + it->second.intervalMs; + ++it; + } else { + it = gTimers.erase(it); + } + } else { + ++it; + } + } + } + + RPCRuntimeExecutor executor = gBgTimerExecutor; + if (!executor) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + for (auto &pair : toFire) { + int64_t id = pair.first; + std::shared_ptr cb = pair.second; + executor([id, cb](jsi::Runtime &rt) { + fireTimerOnJsThread(id, cb, rt); + }); + } + } +} + +static void ensureTimerWorkerStarted() { + bool expected = false; + if (gTimerWorkerStarted.compare_exchange_strong(expected, true)) { + std::thread(timerWorkerLoop).detach(); + LOGI("Timer worker thread started"); + } +} + +static int64_t scheduleTimer( + std::shared_ptr cb, + double ms, + bool isInterval) { + int64_t id = gNextTimerId.fetch_add(1); + long long intervalMs = isInterval ? static_cast(ms) : 0; + long long delay = static_cast(ms); + if (delay < 0) delay = 0; + { + std::lock_guard lock(gTimerMutex); + gTimers[id] = TimerEntry{ + std::move(cb), + nowMs() + delay, + intervalMs, + false, + }; + } + gTimerCv.notify_all(); + ensureTimerWorkerStarted(); + return id; +} + +static void cancelTimer(int64_t id) { + { + std::lock_guard lock(gTimerMutex); + auto it = gTimers.find(id); + if (it != gTimers.end()) { + it->second.cancelled = true; + } + } + gTimerCv.notify_all(); +} + +static void installTimersOnRuntime(jsi::Runtime &rt) { + auto makeSetter = [](bool isInterval) { + return [isInterval]( + jsi::Runtime &rt, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || + !args[0].getObject(rt).isFunction(rt)) { + return jsi::Value::undefined(); + } + auto cb = std::make_shared( + args[0].getObject(rt).getFunction(rt)); + double ms = 0; + if (count >= 2 && args[1].isNumber()) { + ms = args[1].asNumber(); + } + int64_t id = scheduleTimer(std::move(cb), ms, isInterval); + return jsi::Value(static_cast(id)); + }; + }; + auto makeCanceller = []() { + return [](jsi::Runtime &rt, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isNumber()) { + return jsi::Value::undefined(); + } + int64_t id = static_cast(args[0].asNumber()); + cancelTimer(id); + return jsi::Value::undefined(); + }; + }; + + // requestAnimationFrame(cb): fires after ~16ms (60fps) with high-resolution + // timestamp arg, matching the DOM contract. Background runtime has no + // rendering concept, so we just approximate via setTimeout(16ms). + auto rafFn = [](jsi::Runtime &rt, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || + !args[0].getObject(rt).isFunction(rt)) { + return jsi::Value::undefined(); + } + // Wrap callback so it receives a DOMHighResTimeStamp-like arg. + auto userCb = std::make_shared( + args[0].getObject(rt).getFunction(rt)); + auto wrapper = jsi::Function::createFromHostFunction( + rt, + jsi::PropNameID::forAscii(rt, "rafWrapper"), + 0, + [userCb](jsi::Runtime &rt2, + const jsi::Value &, + const jsi::Value *, + size_t) -> jsi::Value { + try { + userCb->call(rt2, jsi::Value(static_cast(nowMs()))); + } catch (const jsi::JSError &e) { + LOGE("rAF callback JSError: %s", e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("rAF callback error: %s", e.what()); + } + return jsi::Value::undefined(); + }); + auto wrappedCb = std::make_shared(std::move(wrapper)); + int64_t id = scheduleTimer(std::move(wrappedCb), 16.0, false); + return jsi::Value(static_cast(id)); + }; + + // requestIdleCallback(cb, {timeout?}): fires "soon" with an IdleDeadline-ish + // object. Background runtime has no render frames to be idle between, so + // we approximate via setTimeout(1ms) and provide a deadline stub whose + // timeRemaining() always returns 50 (reasonable budget). + auto ricFn = [](jsi::Runtime &rt, + const jsi::Value &, + const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || + !args[0].getObject(rt).isFunction(rt)) { + return jsi::Value::undefined(); + } + auto userCb = std::make_shared( + args[0].getObject(rt).getFunction(rt)); + auto wrapper = jsi::Function::createFromHostFunction( + rt, + jsi::PropNameID::forAscii(rt, "ricWrapper"), + 0, + [userCb](jsi::Runtime &rt2, + const jsi::Value &, + const jsi::Value *, + size_t) -> jsi::Value { + try { + // Build a minimal IdleDeadline: { didTimeout: false, + // timeRemaining: () => 50 }. + jsi::Object deadline(rt2); + deadline.setProperty(rt2, "didTimeout", jsi::Value(false)); + deadline.setProperty( + rt2, + "timeRemaining", + jsi::Function::createFromHostFunction( + rt2, + jsi::PropNameID::forAscii(rt2, "timeRemaining"), + 0, + [](jsi::Runtime &, + const jsi::Value &, + const jsi::Value *, + size_t) -> jsi::Value { + return jsi::Value(50.0); + })); + userCb->call(rt2, jsi::Value(rt2, std::move(deadline))); + } catch (const jsi::JSError &e) { + LOGE("rIC callback JSError: %s", e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("rIC callback error: %s", e.what()); + } + return jsi::Value::undefined(); + }); + auto wrappedCb = std::make_shared(std::move(wrapper)); + int64_t id = scheduleTimer(std::move(wrappedCb), 1.0, false); + return jsi::Value(static_cast(id)); + }; + + auto global = rt.global(); + global.setProperty( + rt, "setTimeout", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "setTimeout"), 2, + makeSetter(false))); + global.setProperty( + rt, "setInterval", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "setInterval"), 2, + makeSetter(true))); + global.setProperty( + rt, "clearTimeout", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "clearTimeout"), 1, + makeCanceller())); + global.setProperty( + rt, "clearInterval", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "clearInterval"), 1, + makeCanceller())); + global.setProperty( + rt, "requestAnimationFrame", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "requestAnimationFrame"), 1, + rafFn)); + global.setProperty( + rt, "cancelAnimationFrame", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "cancelAnimationFrame"), 1, + makeCanceller())); + global.setProperty( + rt, "requestIdleCallback", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "requestIdleCallback"), 1, + ricFn)); + global.setProperty( + rt, "cancelIdleCallback", + jsi::Function::createFromHostFunction( + rt, jsi::PropNameID::forAscii(rt, "cancelIdleCallback"), 1, + makeCanceller())); + LOGI("Timer + rAF + rIC polyfills installed on bg runtime"); +} + +// ── nativeInstallSharedBridge ─────────────────────────────────────────── +// Install SharedStore and SharedRPC into a runtime. +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeInstallSharedBridge( + JNIEnv *env, jobject thiz, jlong runtimePtr, jboolean isMain) { + + auto *rt = reinterpret_cast(runtimePtr); + if (!rt) return; + + SharedStore::install(*rt); + + // Create executor that schedules work on this runtime's JS thread via Kotlin. + // Wrap GlobalRef in shared_ptr so it is automatically released when all + // copies of the executor lambda are destroyed (e.g. on runtime reload). + auto ref = std::shared_ptr<_jobject>(env->NewGlobalRef(thiz), [](jobject r) { + if (r) { + JNIEnv *e = getJNIEnv(); + if (e) e->DeleteGlobalRef(r); + } + }); + bool capturedIsMain = static_cast(isMain); + + RPCRuntimeExecutor executor = [ref, capturedIsMain](std::function work) { + JNIEnv *env = getJNIEnv(); + if (!env || !ref) { + LOGE("executor: env=%p, ref=%p — aborting", env, ref.get()); + return; + } + + int64_t workId; + { + std::lock_guard lock(gWorkMutex); + workId = gNextWorkId++; + gPendingWork[workId] = std::move(work); + } + + jclass cls = env->GetObjectClass(ref.get()); + jmethodID mid = env->GetMethodID(cls, "scheduleOnJSThread", "(ZJ)V"); + if (mid) { + LOGI("executor: calling scheduleOnJSThread(isMain=%d, workId=%ld)", capturedIsMain, (long)workId); + env->CallVoidMethod(ref.get(), mid, static_cast(capturedIsMain), static_cast(workId)); + if (env->ExceptionCheck()) { + LOGE("executor: JNI exception after scheduleOnJSThread"); + env->ExceptionDescribe(); + env->ExceptionClear(); + } + } else { + LOGE("executor: scheduleOnJSThread method not found!"); + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } + } + env->DeleteLocalRef(cls); + }; + + std::string runtimeId = isMain ? "main" : "background"; + // Save the bg executor so our custom timer worker can dispatch callbacks + // back to the bg JS queue. We must do this BEFORE moving `executor` into + // SharedRPC::install (which will std::move it out). + if (!capturedIsMain) { + gBgTimerExecutor = executor; + } + SharedRPC::install(*rt, std::move(executor), runtimeId); + LOGI("SharedStore and SharedRPC installed (isMain=%d)", static_cast(isMain)); + if (!capturedIsMain) { + // Install setTimeout/setInterval/clearTimeout/clearInterval on the + // background runtime. React Native's built-in timer module only wires + // into the main runtime, so without this, any `await wait(ms)` or + // setTimeout callback in the background thread would never fire. + installTimersOnRuntime(*rt); + invokeOptionalGlobalFunction(*rt, "__setupBackgroundRPCHandler"); + } +} + +// ── nativeSetupErrorHandler ───────────────────────────────────────────── +// Wrap the global error handler in the background runtime. +// Mirrors iOS BackgroundRunnerReactNativeDelegate.setupErrorHandler:. +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeSetupErrorHandler( + JNIEnv *env, jobject thiz, jlong runtimePtr) { + + auto *runtime = reinterpret_cast(runtimePtr); + if (!runtime) return; + + try { + jsi::Object global = runtime->global(); + jsi::Value errorUtilsVal = global.getProperty(*runtime, "ErrorUtils"); + if (!errorUtilsVal.isObject()) { + LOGE("ErrorUtils is not available on global object"); + return; + } + + jsi::Object errorUtils = errorUtilsVal.asObject(*runtime); + + // Capture the current global error handler + auto originalHandler = std::make_shared( + errorUtils.getProperty(*runtime, "getGlobalHandler") + .asObject(*runtime).asFunction(*runtime).call(*runtime)); + + // Create a custom handler that delegates to the original + auto handlerFunc = jsi::Function::createFromHostFunction( + *runtime, + jsi::PropNameID::forAscii(*runtime, "customGlobalErrorHandler"), + 2, + [originalHandler]( + jsi::Runtime &rt, const jsi::Value &, + const jsi::Value *args, size_t count) -> jsi::Value { + if (count < 2) { + return jsi::Value::undefined(); + } + + if (originalHandler->isObject() && + originalHandler->asObject(rt).isFunction(rt)) { + jsi::Function original = + originalHandler->asObject(rt).asFunction(rt); + original.call(rt, args, count); + } + + return jsi::Value::undefined(); + }); + + // Set the new global error handler + jsi::Function setHandler = + errorUtils.getProperty(*runtime, "setGlobalHandler") + .asObject(*runtime).asFunction(*runtime); + setHandler.call(*runtime, {std::move(handlerFunc)}); + + // Disable further setGlobalHandler from background JS + stubJsiFunction(*runtime, errorUtils, "setGlobalHandler"); + + LOGI("Error handler installed in background runtime"); + } catch (const jsi::JSError &e) { + LOGE("JSError setting up error handler: %s", e.getMessage().c_str()); + } catch (const std::exception &e) { + LOGE("Error setting up error handler: %s", e.what()); + } +} + +// ── nativeDestroy ─────────────────────────────────────────────────────── +// Clean up native resources. +// Called from BackgroundThreadManager.destroy(). +extern "C" JNIEXPORT void JNICALL +Java_com_backgroundthread_BackgroundThreadManager_nativeDestroy( + JNIEnv *env, jobject thiz) { + + SharedRPC::reset(); + SharedStore::reset(); + + LOGI("Native resources cleaned up"); +} diff --git a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BTLogger.kt b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BTLogger.kt new file mode 100644 index 00000000..e8bc71e4 --- /dev/null +++ b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BTLogger.kt @@ -0,0 +1,55 @@ +package com.backgroundthread + +/** + * Lightweight logging wrapper that dynamically dispatches to OneKeyLog. + * Uses reflection to avoid a hard dependency on the native-logger module. + * Falls back to android.util.Log when OneKeyLog is not available. + * + * Mirrors iOS BTLogger. + */ +object BTLogger { + private const val TAG = "BackgroundThread" + + private val logClass: Class<*>? by lazy { + try { + Class.forName("com.margelo.nitro.nativelogger.OneKeyLog") + } catch (_: ClassNotFoundException) { + null + } + } + + private val methods by lazy { + val cls = logClass ?: return@lazy null + mapOf( + "debug" to cls.getMethod("debug", String::class.java, String::class.java), + "info" to cls.getMethod("info", String::class.java, String::class.java), + "warn" to cls.getMethod("warn", String::class.java, String::class.java), + "error" to cls.getMethod("error", String::class.java, String::class.java), + ) + } + + @JvmStatic + fun debug(message: String) = log("debug", message, android.util.Log.DEBUG) + + @JvmStatic + fun info(message: String) = log("info", message, android.util.Log.INFO) + + @JvmStatic + fun warn(message: String) = log("warn", message, android.util.Log.WARN) + + @JvmStatic + fun error(message: String) = log("error", message, android.util.Log.ERROR) + + private fun log(level: String, message: String, androidLogLevel: Int) { + val method = methods?.get(level) + if (method != null) { + try { + method.invoke(null, TAG, message) + return + } catch (_: Exception) { + // Fall through to android.util.Log + } + } + android.util.Log.println(androidLogLevel, TAG, message) + } +} diff --git a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt new file mode 100644 index 00000000..b997680f --- /dev/null +++ b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadManager.kt @@ -0,0 +1,449 @@ +package com.backgroundthread + +import android.net.Uri +import com.facebook.react.ReactPackage +import com.facebook.proguard.annotations.DoNotStrip +import com.facebook.react.ReactInstanceEventListener +import com.facebook.react.bridge.JSBundleLoader +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContext +import com.facebook.react.common.annotations.UnstableReactNativeAPI +import com.facebook.react.defaults.DefaultComponentsRegistry +import com.facebook.react.defaults.DefaultReactHostDelegate +import com.facebook.react.defaults.DefaultTurboModuleManagerDelegate +import com.facebook.react.fabric.ComponentFactory +import com.facebook.react.runtime.ReactHostImpl +import com.facebook.react.runtime.hermes.HermesInstance +import com.facebook.react.shell.MainReactPackage +import java.io.File + +/** + * Singleton manager for the background React Native runtime. + * Mirrors iOS BackgroundThreadManager. + * + * Responsibilities: + * - Manages background ReactHostImpl lifecycle + * - Installs SharedBridge into main and background runtimes + * - Cross-runtime communication via SharedRPC onWrite notifications + */ +class BackgroundThreadManager private constructor() { + + private var bgReactHost: ReactHostImpl? = null + private var reactPackages: List = emptyList() + + @Volatile + private var bgRuntimePtr: Long = 0 + + @Volatile + private var mainRuntimePtr: Long = 0 + private var mainReactContext: ReactApplicationContext? = null + private var isStarted = false + + companion object { + private const val MODULE_NAME = "background" + + init { + System.loadLibrary("background_thread") + } + + @Volatile + private var instance: BackgroundThreadManager? = null + + @JvmStatic + fun getInstance(): BackgroundThreadManager { + return instance ?: synchronized(this) { + instance ?: BackgroundThreadManager().also { instance = it } + } + } + } + + // ── JNI declarations ──────────────────────────────────────────────────── + + private external fun nativeInstallSharedBridge(runtimePtr: Long, isMain: Boolean) + private external fun nativeSetupErrorHandler(runtimePtr: Long) + private external fun nativeDestroy() + private external fun nativeExecuteWork(runtimePtr: Long, workId: Long) + + // ── SharedBridge ──────────────────────────────────────────────────────── + + /** + * Install SharedBridge HostObject into the main (UI) runtime. + * Call this from installSharedBridge(). + */ + fun setReactPackages(packages: List) { + reactPackages = packages.toList() + } + + fun installSharedBridgeInMainRuntime(context: ReactApplicationContext) { + mainReactContext = context + context.runOnJSQueueThread { + try { + val ptr = context.javaScriptContextHolder?.get() ?: 0L + if (ptr != 0L) { + mainRuntimePtr = ptr + nativeInstallSharedBridge(ptr, true) + BTLogger.info("SharedBridge installed in main runtime") + } else { + BTLogger.warn("Main runtime pointer is 0, cannot install SharedBridge") + } + } catch (e: Exception) { + BTLogger.error("Error installing SharedBridge in main runtime: ${e.message}") + } + } + } + + // ── Background runner lifecycle ───────────────────────────────────────── + + private fun isRemoteBundleUrl(entryURL: String): Boolean { + return entryURL.startsWith("http://") || entryURL.startsWith("https://") + } + + private fun resolveLocalBundlePath(entryURL: String): String? { + if (entryURL.startsWith("file://")) { + return Uri.parse(entryURL).path + } + if (entryURL.startsWith("/")) { + return entryURL + } + return null + } + + /** + * Creates a JSBundleLoader that loads two bundles sequentially from Android assets: + * first the common bundle (polyfills + shared modules), then the + * entry-specific bundle (entry-only modules + require(entryId)). + */ + private fun createSequentialAssetBundleLoader( + appContext: android.content.Context, + commonAssetName: String, + entryAssetName: String + ): JSBundleLoader { + return object : JSBundleLoader() { + override fun loadScript(delegate: com.facebook.react.bridge.JSBundleLoaderDelegate): String { + val totalStart = System.nanoTime() + + // Step 1: Load common bundle (polyfills + shared modules) + val commonStart = System.nanoTime() + delegate.loadScriptFromAssets(appContext.assets, "assets://$commonAssetName", false) + val commonMs = (System.nanoTime() - commonStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] common bundle loaded from assets in ${String.format("%.1f", commonMs)}ms: $commonAssetName") + + // Step 2: Load entry-specific bundle + val entryStart = System.nanoTime() + delegate.loadScriptFromAssets(appContext.assets, "assets://$entryAssetName", false) + val entryMs = (System.nanoTime() - entryStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] entry bundle loaded from assets in ${String.format("%.1f", entryMs)}ms: $entryAssetName") + + val totalMs = (System.nanoTime() - totalStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] sequential asset load total: ${String.format("%.1f", totalMs)}ms (common=${String.format("%.1f", commonMs)}ms + entry=${String.format("%.1f", entryMs)}ms)") + + return "assets://$entryAssetName" + } + } + } + + /** + * Creates a JSBundleLoader that loads two bundles sequentially from local files: + * first the common bundle, then the entry-specific bundle. + */ + private fun createSequentialFileBundleLoader( + commonPath: String, + entryPath: String, + entrySourceURL: String + ): JSBundleLoader { + return object : JSBundleLoader() { + override fun loadScript(delegate: com.facebook.react.bridge.JSBundleLoaderDelegate): String { + val totalStart = System.nanoTime() + + // Step 1: Load common bundle (polyfills + shared modules) + val commonFile = File(commonPath) + if (!commonFile.exists()) { + BTLogger.error("Common bundle file does not exist: $commonPath") + throw RuntimeException("Common bundle file does not exist: $commonPath") + } + BTLogger.info("[SplitBundle] common bundle file: ${commonFile.length() / 1024}KB") + val commonStart = System.nanoTime() + delegate.loadScriptFromFile(commonFile.absolutePath, "common.bundle", false) + val commonMs = (System.nanoTime() - commonStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] common bundle loaded from file in ${String.format("%.1f", commonMs)}ms: $commonPath") + + // Step 2: Load entry-specific bundle + val entryFile = File(entryPath) + if (!entryFile.exists()) { + BTLogger.error("Entry bundle file does not exist: $entryPath") + throw RuntimeException("Entry bundle file does not exist: $entryPath") + } + BTLogger.info("[SplitBundle] entry bundle file: ${entryFile.length() / 1024}KB") + val entryStart = System.nanoTime() + delegate.loadScriptFromFile(entryFile.absolutePath, entrySourceURL, false) + val entryMs = (System.nanoTime() - entryStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] entry bundle loaded from file in ${String.format("%.1f", entryMs)}ms: $entryPath") + + val totalMs = (System.nanoTime() - totalStart) / 1_000_000.0 + BTLogger.info("[SplitBundle] sequential file load total: ${String.format("%.1f", totalMs)}ms (common=${String.format("%.1f", commonMs)}ms + entry=${String.format("%.1f", entryMs)}ms)") + + return entrySourceURL + } + } + } + + /** + * Check if common.bundle exists in the Android assets directory. + */ + private fun hasCommonBundleInAssets(appContext: android.content.Context): Boolean { + return try { + appContext.assets.open("common.bundle").close() + true + } catch (e: Exception) { + false + } + } + + /** + * Resolve the common bundle path for OTA (file-based) loading. + * Looks for common.bundle in the same directory as the entry bundle. + */ + private fun resolveCommonBundlePath(entryBundlePath: String): String? { + val entryFile = File(entryBundlePath) + val parentDir = entryFile.parentFile ?: return null + val commonFile = File(parentDir, "common.bundle") + return if (commonFile.exists()) commonFile.absolutePath else null + } + + private fun createDownloadedBundleLoader(appContext: android.content.Context, entryURL: String): JSBundleLoader { + return object : JSBundleLoader() { + override fun loadScript(delegate: com.facebook.react.bridge.JSBundleLoaderDelegate): String { + val tempFile = File(appContext.cacheDir, "background.bundle") + try { + java.net.URL(entryURL).openStream().use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BTLogger.info("Background bundle downloaded to ${tempFile.absolutePath}") + } catch (e: Exception) { + BTLogger.error("Failed to download background bundle: ${e.message}") + throw RuntimeException("Failed to download background bundle from $entryURL", e) + } + delegate.loadScriptFromFile(tempFile.absolutePath, entryURL, false) + return entryURL + } + } + } + + private fun createLocalFileBundleLoader(localPath: String, sourceURL: String): JSBundleLoader { + return object : JSBundleLoader() { + override fun loadScript(delegate: com.facebook.react.bridge.JSBundleLoaderDelegate): String { + val bundleFile = File(localPath) + if (!bundleFile.exists()) { + BTLogger.error("Background bundle file does not exist: $localPath") + throw RuntimeException("Background bundle file does not exist: $localPath") + } + delegate.loadScriptFromFile(bundleFile.absolutePath, sourceURL, false) + return sourceURL + } + } + } + + @OptIn(UnstableReactNativeAPI::class) + fun startBackgroundRunnerWithEntryURL(context: ReactApplicationContext, entryURL: String) { + if (isStarted) { + BTLogger.warn("Background runner already started") + return + } + val bgStartTime = System.nanoTime() + BTLogger.info("[SplitBundle] background runner starting with entryURL: $entryURL") + + val appContext = context.applicationContext + val packages = + if (reactPackages.isNotEmpty()) { + reactPackages + } else { + BTLogger.warn("No ReactPackages registered for background runtime; call setReactPackages(...) from host before start. Falling back to MainReactPackage only.") + listOf(MainReactPackage()) + } + + val localBundlePath = resolveLocalBundlePath(entryURL) + val bundleLoader = + when { + // Debug mode: remote URL — use single bundle (Metro dev server) + isRemoteBundleUrl(entryURL) -> createDownloadedBundleLoader(appContext, entryURL) + + // OTA / local file path — try sequential loading with common bundle + localBundlePath != null -> { + val commonPath = resolveCommonBundlePath(localBundlePath) + if (commonPath != null) { + BTLogger.info("Using sequential file bundle loader: common=$commonPath, entry=$localBundlePath") + createSequentialFileBundleLoader(commonPath, localBundlePath, entryURL) + } else { + BTLogger.info("No common bundle found for OTA path, using single bundle: $localBundlePath") + createLocalFileBundleLoader(localBundlePath, entryURL) + } + } + + // Assets-based loading — try sequential loading with common.bundle in assets + entryURL.startsWith("assets://") -> { + val entryAssetName = entryURL.removePrefix("assets://") + if (hasCommonBundleInAssets(appContext)) { + BTLogger.info("Using sequential asset bundle loader: common=common.bundle, entry=$entryAssetName") + createSequentialAssetBundleLoader(appContext, "common.bundle", entryAssetName) + } else { + BTLogger.info("No common.bundle in assets, using single bundle: $entryURL") + JSBundleLoader.createAssetLoader(appContext, entryURL, true) + } + } + + // Bare filename (e.g. "background.bundle") — treat as asset + else -> { + if (hasCommonBundleInAssets(appContext)) { + BTLogger.info("Using sequential asset bundle loader: common=common.bundle, entry=$entryURL") + createSequentialAssetBundleLoader(appContext, "common.bundle", entryURL) + } else { + BTLogger.info("No common.bundle in assets, using single bundle: assets://$entryURL") + JSBundleLoader.createAssetLoader(appContext, "assets://$entryURL", true) + } + } + } + + val delegate = DefaultReactHostDelegate( + jsMainModulePath = MODULE_NAME, + jsBundleLoader = bundleLoader, + reactPackages = packages, + jsRuntimeFactory = HermesInstance(), + turboModuleManagerDelegateBuilder = DefaultTurboModuleManagerDelegate.Builder(), + ) + + val componentFactory = ComponentFactory() + DefaultComponentsRegistry.register(componentFactory) + + val host = ReactHostImpl( + appContext, + delegate, + componentFactory, + true, /* allowPackagerServerAccess */ + false, /* useDevSupport */ + ) + bgReactHost = host + + host.addReactInstanceEventListener(object : ReactInstanceEventListener { + override fun onReactContextInitialized(context: ReactContext) { + val initMs = (System.nanoTime() - bgStartTime) / 1_000_000.0 + BTLogger.info("[SplitBundle] background ReactContext initialized in ${String.format("%.1f", initMs)}ms") + context.runOnJSQueueThread { + try { + val ptr = context.javaScriptContextHolder?.get() ?: 0L + if (ptr != 0L) { + bgRuntimePtr = ptr + nativeInstallSharedBridge(ptr, false) + nativeSetupErrorHandler(ptr) + BTLogger.info("SharedBridge and error handler installed in background runtime") + } else { + BTLogger.error("Background runtime pointer is 0") + } + } catch (e: Exception) { + BTLogger.error("Error installing bindings in background runtime: ${e.message}") + } + } + } + }) + + host.start() + isStarted = true + } + + /** + * Called from C++ RuntimeExecutor to schedule work on the correct JS thread. + * Routes to main or background runtime's JS queue thread, then calls nativeExecuteWork. + */ + @DoNotStrip + fun scheduleOnJSThread(isMain: Boolean, workId: Long) { + val context = if (isMain) mainReactContext else bgReactHost?.currentReactContext + BTLogger.info("scheduleOnJSThread: isMain=$isMain, workId=$workId, context=${context != null}") + if (context == null) { + BTLogger.error("scheduleOnJSThread: context is null! isMain=$isMain, mainCtx=${mainReactContext != null}, bgHost=${bgReactHost != null}, bgCtx=${bgReactHost?.currentReactContext != null}") + } + context?.runOnJSQueueThread { + // Re-read ptr inside the block — if a reload happened between + // scheduling and execution, the old ptr may be stale. + val ptr = if (isMain) mainRuntimePtr else bgRuntimePtr + BTLogger.info("scheduleOnJSThread runOnJSQueueThread: isMain=$isMain, workId=$workId, ptr=$ptr") + if (ptr != 0L) { + try { + nativeExecuteWork(ptr, workId) + } catch (e: Exception) { + BTLogger.error("Error executing work on JS thread: ${e.message}") + } + } else { + BTLogger.error("scheduleOnJSThread: ptr is 0! isMain=$isMain") + } + } + } + + // ── Segment Registration (Phase 2.5 spike) ───────────────────────────── + + /** + * Register a HBC segment in the background runtime. + * Uses CatalystInstance.registerSegment() on the background ReactContext. + * + * @param segmentId The segment ID to register + * @param path Absolute file path to the .seg.hbc file + * @throws IllegalStateException if background runtime is not started + * @throws IllegalArgumentException if segment file does not exist + */ + /** + * Register a HBC segment in the background runtime with completion callback. + * Dispatches to the background JS queue thread and invokes the callback + * only after registerSegment has actually executed. + * + * @param segmentId The segment ID to register + * @param path Absolute file path to the .seg.hbc file + * @param onComplete Called with null on success, or an Exception on failure + */ + fun registerSegmentInBackground(segmentId: Int, path: String, onComplete: (Exception?) -> Unit) { + if (!isStarted) { + onComplete(IllegalStateException("Background runtime not started")) + return + } + + val file = File(path) + if (!file.exists()) { + onComplete(IllegalArgumentException("Segment file not found: $path")) + return + } + + val context = bgReactHost?.currentReactContext + if (context == null) { + onComplete(IllegalStateException("Background ReactContext not available")) + return + } + + context.runOnJSQueueThread { + try { + if (context.hasCatalystInstance()) { + context.catalystInstance.registerSegment(segmentId, path) + BTLogger.info("Segment registered in background runtime: id=$segmentId, path=$path") + onComplete(null) + } else { + onComplete(IllegalStateException("Background CatalystInstance not available for segment registration")) + } + } catch (e: Exception) { + BTLogger.error("Failed to register segment in background runtime: ${e.message}") + onComplete(e) + } + } + } + + // ── Lifecycle ─────────────────────────────────────────────────────────── + + val isBackgroundStarted: Boolean get() = isStarted + + fun destroy() { + nativeDestroy() + bgRuntimePtr = 0 + mainRuntimePtr = 0 + mainReactContext = null + bgReactHost?.destroy("BackgroundThreadManager destroyed", null) + bgReactHost = null + isStarted = false + } +} diff --git a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt index d9572e6e..c67349fc 100644 --- a/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt +++ b/native-modules/react-native-background-thread/android/src/main/java/com/backgroundthread/BackgroundThreadModule.kt @@ -1,29 +1,41 @@ package com.backgroundthread +import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.annotations.ReactModule +/** + * TurboModule entry point for BackgroundThread. + * Delegates all heavy lifting to [BackgroundThreadManager] singleton. + * + * Mirrors iOS BackgroundThread.mm. + */ @ReactModule(name = BackgroundThreadModule.NAME) class BackgroundThreadModule(reactContext: ReactApplicationContext) : - NativeBackgroundThreadSpec(reactContext) { + NativeBackgroundThreadSpec(reactContext) { - override fun getName(): String { - return NAME - } + companion object { + const val NAME = "BackgroundThread" + } - override fun initBackgroundThread() { - // TODO: Implement initBackgroundThread - } + override fun getName(): String = NAME - override fun postBackgroundMessage(message: String) { - // TODO: Implement postBackgroundMessage - } + override fun installSharedBridge() { + BackgroundThreadManager.getInstance().installSharedBridgeInMainRuntime(reactApplicationContext) + } - override fun startBackgroundRunnerWithEntryURL(entryURL: String) { - // TODO: Implement startBackgroundRunnerWithEntryURL - } + override fun startBackgroundRunnerWithEntryURL(entryURL: String) { + BackgroundThreadManager.getInstance().startBackgroundRunnerWithEntryURL(reactApplicationContext, entryURL) + } - companion object { - const val NAME = "BackgroundThread" - } + override fun loadSegmentInBackground(segmentId: Double, path: String, promise: Promise) { + BackgroundThreadManager.getInstance() + .registerSegmentInBackground(segmentId.toInt(), path) { error -> + if (error != null) { + promise.reject("BG_SEGMENT_LOAD_ERROR", error.message, error) + } else { + promise.resolve(null) + } + } + } } diff --git a/native-modules/react-native-background-thread/cpp/SharedRPC.cpp b/native-modules/react-native-background-thread/cpp/SharedRPC.cpp new file mode 100644 index 00000000..d3ee3e65 --- /dev/null +++ b/native-modules/react-native-background-thread/cpp/SharedRPC.cpp @@ -0,0 +1,230 @@ +#include "SharedRPC.h" + +#ifdef __ANDROID__ +#include +#define RPC_LOG(...) __android_log_print(ANDROID_LOG_INFO, "SharedRPC", __VA_ARGS__) +#else +#define RPC_LOG(...) +#endif + +std::mutex SharedRPC::mutex_; +std::unordered_map SharedRPC::slots_; +std::vector SharedRPC::listeners_; + +void SharedRPC::install(jsi::Runtime &rt) { + auto rpc = std::make_shared(); + auto obj = jsi::Object::createFromHostObject(rt, rpc); + rt.global().setProperty(rt, "sharedRPC", std::move(obj)); +} + +void SharedRPC::install(jsi::Runtime &rt, RPCRuntimeExecutor executor, + const std::string &runtimeId) { + auto rpc = std::make_shared(); + auto obj = jsi::Object::createFromHostObject(rt, rpc); + rt.global().setProperty(rt, "sharedRPC", std::move(obj)); + + std::lock_guard lock(mutex_); + // Remove any existing listener with the same runtimeId (reload scenario). + // IMPORTANT: The old listener's callback is a jsi::Function tied to the old + // runtime. On reload, that runtime is already destroyed, so calling + // ~jsi::Function() would crash (null deref in Pointer::~Pointer). + // We intentionally leak the callback to avoid this. + for (auto &listener : listeners_) { + if (listener.runtimeId == runtimeId && listener.callback) { + new std::shared_ptr(std::move(listener.callback)); + } + } + listeners_.erase( + std::remove_if(listeners_.begin(), listeners_.end(), + [&runtimeId](const RuntimeListener &l) { + return l.runtimeId == runtimeId; + }), + listeners_.end()); + listeners_.push_back({runtimeId, &rt, std::move(executor), nullptr}); +} + +void SharedRPC::reset() { + std::lock_guard lock(mutex_); + slots_.clear(); + // Intentionally leak jsi::Function callbacks to avoid destroying them on the + // wrong thread (same rationale as the leak in install() for reload scenarios). + for (auto &listener : listeners_) { + if (listener.callback) { + new std::shared_ptr(std::move(listener.callback)); + } + } + listeners_.clear(); +} + +void SharedRPC::notifyOtherRuntime(jsi::Runtime &callerRt, const std::string &callId) { + // Collect executors and callbacks under lock, then invoke outside lock + // to avoid deadlock (executor may schedule work that also acquires mutex_). + std::vector>> toNotify; + { + std::lock_guard lock(mutex_); + RPC_LOG("notifyOtherRuntime: callId=%s, listeners=%zu, callerRt=%p", + callId.c_str(), listeners_.size(), &callerRt); + for (auto &listener : listeners_) { + RPC_LOG(" listener: id=%s, rt=%p, hasCallback=%d", + listener.runtimeId.c_str(), listener.runtime, listener.callback != nullptr); + if (listener.runtime == &callerRt) continue; + if (!listener.callback) continue; + toNotify.emplace_back(listener.executor, listener.callback); + } + RPC_LOG(" toNotify count: %zu", toNotify.size()); + } + + for (auto &[executor, cb] : toNotify) { + auto id = callId; + RPC_LOG(" invoking executor for callId=%s", id.c_str()); + executor([cb, id](jsi::Runtime &rt) { + RPC_LOG(" executor work running for callId=%s", id.c_str()); + try { + cb->call(rt, jsi::String::createFromUtf8(rt, id)); + RPC_LOG(" cb->call succeeded for callId=%s", id.c_str()); + } catch (const jsi::JSError &e) { + RPC_LOG(" JSError in cb->call: %s", e.getMessage().c_str()); + } catch (...) { + RPC_LOG(" Unknown error in cb->call"); + } + }); + } +} + +RPCValue SharedRPC::extractValue(jsi::Runtime &rt, const jsi::Value &val) { + if (val.isBool()) { + return val.getBool(); + } + if (val.isNumber()) { + return val.getNumber(); + } + if (val.isString()) { + return val.getString(rt).utf8(rt); + } + throw jsi::JSError(rt, + "SharedRPC: unsupported value type. " + "Only bool, number, and string are supported."); +} + +jsi::Value SharedRPC::toJSI(jsi::Runtime &rt, const RPCValue &val) { + if (std::holds_alternative(val)) { + return jsi::Value(std::get(val)); + } + if (std::holds_alternative(val)) { + return jsi::Value(std::get(val)); + } + // std::string + return jsi::String::createFromUtf8(rt, std::get(val)); +} + +jsi::Value SharedRPC::get(jsi::Runtime &rt, const jsi::PropNameID &name) { + auto prop = name.utf8(rt); + + // write(callId: string, value: bool | number | string): void + if (prop == "write") { + return jsi::Function::createFromHostFunction( + rt, name, 2, + [this](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 2 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedRPC.write expects (callId: string, value)"); + } + auto callId = args[0].getString(rt).utf8(rt); + auto value = extractValue(rt, args[1]); + { + std::lock_guard lock(mutex_); + slots_.insert_or_assign(callId, std::move(value)); + } + // Notify OUTSIDE the lock + notifyOtherRuntime(rt, callId); + return jsi::Value::undefined(); + }); + } + + // read(callId: string): bool | number | string | undefined + // Deletes the entry after reading (read-and-delete semantics). + if (prop == "read") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedRPC.read expects (callId: string)"); + } + auto callId = args[0].getString(rt).utf8(rt); + { + std::lock_guard lock(mutex_); + auto it = slots_.find(callId); + if (it == slots_.end()) { + return jsi::Value::undefined(); + } + auto value = std::move(it->second); + slots_.erase(it); + return toJSI(rt, value); + } + }); + } + + // has(callId: string): boolean + if (prop == "has") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedRPC.has expects (callId: string)"); + } + auto callId = args[0].getString(rt).utf8(rt); + { + std::lock_guard lock(mutex_); + return jsi::Value(slots_.count(callId) > 0); + } + }); + } + + // pendingCount: number (getter, not a function) + if (prop == "pendingCount") { + std::lock_guard lock(mutex_); + return jsi::Value(static_cast(slots_.size())); + } + + // onWrite(callback: (callId: string) => void): void + if (prop == "onWrite") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isObject() || + !args[0].asObject(rt).isFunction(rt)) { + throw jsi::JSError(rt, "SharedRPC.onWrite expects a function"); + } + auto fn = std::make_shared( + args[0].asObject(rt).asFunction(rt)); + { + std::lock_guard lock(mutex_); + for (auto &listener : listeners_) { + if (listener.runtime == &rt) { + listener.callback = std::move(fn); + break; + } + } + } + return jsi::Value::undefined(); + }); + } + + return jsi::Value::undefined(); +} + +std::vector SharedRPC::getPropertyNames(jsi::Runtime &rt) { + std::vector props; + props.push_back(jsi::PropNameID::forUtf8(rt, "write")); + props.push_back(jsi::PropNameID::forUtf8(rt, "read")); + props.push_back(jsi::PropNameID::forUtf8(rt, "has")); + props.push_back(jsi::PropNameID::forUtf8(rt, "pendingCount")); + props.push_back(jsi::PropNameID::forUtf8(rt, "onWrite")); + return props; +} diff --git a/native-modules/react-native-background-thread/cpp/SharedRPC.h b/native-modules/react-native-background-thread/cpp/SharedRPC.h new file mode 100644 index 00000000..570e04e7 --- /dev/null +++ b/native-modules/react-native-background-thread/cpp/SharedRPC.h @@ -0,0 +1,50 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace jsi = facebook::jsi; + +using RPCValue = std::variant; + +// Executes a callback on a specific runtime's JS thread. +// The implementation is platform-specific (iOS vs Android). +using RPCRuntimeExecutor = std::function)>; + +struct RuntimeListener { + std::string runtimeId; // "main" or "background" + jsi::Runtime *runtime; + RPCRuntimeExecutor executor; + std::shared_ptr callback; // JS onWrite callback +}; + +class SharedRPC : public jsi::HostObject { +public: + jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override; + std::vector getPropertyNames(jsi::Runtime &rt) override; + + /// Legacy install without executor (no cross-runtime notification) + static void install(jsi::Runtime &rt); + + /// Install with executor — enables cross-runtime write notifications + /// runtimeId should be "main" or "background" — used for dedup on reload. + static void install(jsi::Runtime &rt, RPCRuntimeExecutor executor, + const std::string &runtimeId); + + static void reset(); + +private: + static RPCValue extractValue(jsi::Runtime &rt, const jsi::Value &val); + static jsi::Value toJSI(jsi::Runtime &rt, const RPCValue &val); + void notifyOtherRuntime(jsi::Runtime &callerRt, const std::string &callId); + + static std::mutex mutex_; + static std::unordered_map slots_; + static std::vector listeners_; +}; diff --git a/native-modules/react-native-background-thread/cpp/SharedStore.cpp b/native-modules/react-native-background-thread/cpp/SharedStore.cpp new file mode 100644 index 00000000..4c1be8ad --- /dev/null +++ b/native-modules/react-native-background-thread/cpp/SharedStore.cpp @@ -0,0 +1,184 @@ +#include "SharedStore.h" + +// Static member definitions +std::mutex SharedStore::mutex_; +std::unordered_map SharedStore::data_; + +void SharedStore::install(jsi::Runtime &rt) { + auto store = std::make_shared(); + auto obj = jsi::Object::createFromHostObject(rt, store); + rt.global().setProperty(rt, "sharedStore", std::move(obj)); +} + +void SharedStore::reset() { + std::lock_guard lock(mutex_); + data_.clear(); +} + +StoreValue SharedStore::extractValue(jsi::Runtime &rt, + const jsi::Value &val) { + if (val.isBool()) { + return val.getBool(); + } + if (val.isNumber()) { + return val.getNumber(); + } + if (val.isString()) { + return val.getString(rt).utf8(rt); + } + throw jsi::JSError(rt, + "SharedStore: unsupported value type. " + "Only bool, number, and string are supported."); +} + +jsi::Value SharedStore::toJSI(jsi::Runtime &rt, const StoreValue &val) { + if (std::holds_alternative(val)) { + return jsi::Value(std::get(val)); + } + if (std::holds_alternative(val)) { + return jsi::Value(std::get(val)); + } + if (std::holds_alternative(val)) { + return jsi::String::createFromUtf8(rt, std::get(val)); + } + return jsi::Value::undefined(); +} + +jsi::Value SharedStore::get(jsi::Runtime &rt, const jsi::PropNameID &name) { + auto prop = name.utf8(rt); + + // set(key: string, value: bool | number | string): void + if (prop == "set") { + return jsi::Function::createFromHostFunction( + rt, name, 2, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 2 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedStore.set expects (string key, value)"); + } + auto key = args[0].getString(rt).utf8(rt); + auto val = extractValue(rt, args[1]); + { + std::lock_guard lock(mutex_); + data_[std::move(key)] = std::move(val); + } + return jsi::Value::undefined(); + }); + } + + // get(key: string): bool | number | string | undefined + if (prop == "get") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedStore.get expects a string key"); + } + auto key = args[0].getString(rt).utf8(rt); + { + std::lock_guard lock(mutex_); + auto it = data_.find(key); + if (it == data_.end()) { + return jsi::Value::undefined(); + } + return toJSI(rt, it->second); + } + }); + } + + // has(key: string): boolean + if (prop == "has") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedStore.has expects a string key"); + } + auto key = args[0].getString(rt).utf8(rt); + { + std::lock_guard lock(mutex_); + return jsi::Value(data_.count(key) > 0); + } + }); + } + + // delete(key: string): boolean + if (prop == "delete") { + return jsi::Function::createFromHostFunction( + rt, name, 1, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, + size_t count) -> jsi::Value { + if (count < 1 || !args[0].isString()) { + throw jsi::JSError( + rt, "SharedStore.delete expects a string key"); + } + auto key = args[0].getString(rt).utf8(rt); + { + std::lock_guard lock(mutex_); + return jsi::Value(data_.erase(key) > 0); + } + }); + } + + // keys(): string[] + if (prop == "keys") { + return jsi::Function::createFromHostFunction( + rt, name, 0, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, + size_t) -> jsi::Value { + std::vector allKeys; + { + std::lock_guard lock(mutex_); + allKeys.reserve(data_.size()); + for (const auto &pair : data_) { + allKeys.push_back(pair.first); + } + } + auto arr = jsi::Array(rt, allKeys.size()); + for (size_t i = 0; i < allKeys.size(); i++) { + arr.setValueAtIndex( + rt, i, jsi::String::createFromUtf8(rt, allKeys[i])); + } + return arr; + }); + } + + // clear(): void + if (prop == "clear") { + return jsi::Function::createFromHostFunction( + rt, name, 0, + [](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *, + size_t) -> jsi::Value { + { + std::lock_guard lock(mutex_); + data_.clear(); + } + return jsi::Value::undefined(); + }); + } + + // size: number (getter, not a function) + if (prop == "size") { + std::lock_guard lock(mutex_); + return jsi::Value(static_cast(data_.size())); + } + + return jsi::Value::undefined(); +} + +std::vector SharedStore::getPropertyNames(jsi::Runtime &rt) { + std::vector props; + props.push_back(jsi::PropNameID::forUtf8(rt, "set")); + props.push_back(jsi::PropNameID::forUtf8(rt, "get")); + props.push_back(jsi::PropNameID::forUtf8(rt, "has")); + props.push_back(jsi::PropNameID::forUtf8(rt, "delete")); + props.push_back(jsi::PropNameID::forUtf8(rt, "keys")); + props.push_back(jsi::PropNameID::forUtf8(rt, "clear")); + props.push_back(jsi::PropNameID::forUtf8(rt, "size")); + return props; +} diff --git a/native-modules/react-native-background-thread/cpp/SharedStore.h b/native-modules/react-native-background-thread/cpp/SharedStore.h new file mode 100644 index 00000000..b7e17123 --- /dev/null +++ b/native-modules/react-native-background-thread/cpp/SharedStore.h @@ -0,0 +1,28 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace jsi = facebook::jsi; + +using StoreValue = std::variant; + +class SharedStore : public jsi::HostObject { +public: + jsi::Value get(jsi::Runtime &rt, const jsi::PropNameID &name) override; + std::vector getPropertyNames(jsi::Runtime &rt) override; + + static void install(jsi::Runtime &rt); + static void reset(); + +private: + static StoreValue extractValue(jsi::Runtime &rt, const jsi::Value &val); + static jsi::Value toJSI(jsi::Runtime &rt, const StoreValue &val); + + static std::mutex mutex_; + static std::unordered_map data_; +}; diff --git a/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.h b/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.h index 2b4cb138..0b7c8823 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.h +++ b/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.h @@ -18,7 +18,6 @@ NS_ASSUME_NONNULL_BEGIN @interface BackgroundReactNativeDelegate : RCTDefaultReactNativeFactoryDelegate -//@property (nonatomic) std::shared_ptr eventEmitter; @property (nonatomic, assign) BOOL hasOnMessageHandler; @property (nonatomic, assign) BOOL hasOnErrorHandler; @@ -26,8 +25,6 @@ NS_ASSUME_NONNULL_BEGIN @property (nonatomic, readwrite) std::string origin; -@property (nonatomic, copy) void (^onMessageCallback)(NSString *message); - /** * Initializes the delegate. * @return Initialized delegate instance with filtered module access @@ -35,18 +32,11 @@ NS_ASSUME_NONNULL_BEGIN - (instancetype)init; /** - * Posts a message to the JavaScript runtime. - * @param message C++ string containing the JSON.stringified message - */ -- (void)postMessage:(const std::string &)message; - -/** - * Routes a message to a specific sandbox delegate. - * @param message The message to route - * @param targetId The ID of the target sandbox - * @return true if the message was successfully routed, false otherwise + * Register a HBC segment in the background runtime (Phase 2.5 spike). + * Uses RCTInstance's registerSegmentWithId:path: API. + * Must be called after hostDidStart: has completed. */ -- (bool)routeMessage:(const std::string &)message toSandbox:(const std::string &)targetId; +- (BOOL)registerSegmentWithId:(NSNumber *)segmentId path:(NSString *)path; @end diff --git a/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.mm b/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.mm index 503c712a..a17700ca 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.mm +++ b/native-modules/react-native-background-thread/ios/BackgroundRunnerReactNativeDelegate.mm @@ -8,6 +8,9 @@ #include #include +#include "SharedStore.h" +#include "SharedRPC.h" + #import #import @@ -51,26 +54,75 @@ static void stubJsiFunction(jsi::Runtime &runtime, jsi::Object &object, const ch })); } -static std::string safeGetStringProperty(jsi::Runtime &rt, const jsi::Object &obj, const char *key) +static void invokeOptionalGlobalFunction(jsi::Runtime &runtime, const char *name) { - if (!obj.hasProperty(rt, key)) { - return ""; + try { + jsi::Value fnValue = runtime.global().getProperty(runtime, name); + if (!fnValue.isObject() || !fnValue.asObject(runtime).isFunction(runtime)) { + return; + } + + jsi::Function fn = fnValue.asObject(runtime).asFunction(runtime); + fn.call(runtime); + } catch (const jsi::JSError &e) { + [BTLogger error:[NSString stringWithFormat:@"JSError calling global function %s: %s", name, e.getMessage().c_str()]]; + } catch (const std::exception &e) { + [BTLogger error:[NSString stringWithFormat:@"Error calling global function %s: %s", name, e.what()]]; } - jsi::Value value = obj.getProperty(rt, key); - return value.isString() ? value.getString(rt).utf8(rt) : ""; +} + +static NSURL *resolveMainBundleResourceURL(NSString *resourceName) +{ + if (resourceName.length == 0) { + return nil; + } + + NSURL *directURL = [[NSBundle mainBundle] URLForResource:resourceName withExtension:nil]; + if (directURL) { + return directURL; + } + + NSString *normalizedName = [resourceName hasPrefix:@"/"] + ? resourceName.lastPathComponent + : resourceName; + NSString *extension = normalizedName.pathExtension; + NSString *baseName = normalizedName.stringByDeletingPathExtension; + if (baseName.length == 0) { + return nil; + } + + return [[NSBundle mainBundle] URLForResource:baseName + withExtension:extension.length > 0 ? extension : nil]; +} + +static NSURL *resolveBundleSourceURL(NSString *jsBundleSourceNS) +{ + if (jsBundleSourceNS.length == 0) { + return nil; + } + + NSURL *parsedURL = [NSURL URLWithString:jsBundleSourceNS]; + if (parsedURL.scheme.length > 0) { + if (parsedURL.isFileURL && parsedURL.path.length > 0) { + return [NSURL fileURLWithPath:parsedURL.path]; + } + return parsedURL; + } + + if ([jsBundleSourceNS hasPrefix:@"/"]) { + return [NSURL fileURLWithPath:jsBundleSourceNS]; + } + + return resolveMainBundleResourceURL(jsBundleSourceNS); } @interface BackgroundReactNativeDelegate () { RCTInstance *_rctInstance; - std::shared_ptr _onMessageSandbox; std::string _origin; std::string _jsBundleSource; } - (void)cleanupResources; - -- (jsi::Function)createPostMessageFunction:(jsi::Runtime &)runtime; -- (jsi::Function)createSetOnMessageFunction:(jsi::Runtime &)runtime; - (void)setupErrorHandler:(jsi::Runtime &)runtime; @end @@ -91,7 +143,6 @@ - (instancetype)init - (void)cleanupResources { - _onMessageSandbox.reset(); _rctInstance = nil; } @@ -119,58 +170,31 @@ - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge - (NSURL *)bundleURL { + // When _jsBundleSource is set (dev mode or explicit override), use it as-is. + // This is a single full bundle (not split), so DON'T use common+entry strategy. if (!_jsBundleSource.empty()) { NSString *jsBundleSourceNS = [NSString stringWithUTF8String:_jsBundleSource.c_str()]; - NSURL *url = [NSURL URLWithString:jsBundleSourceNS]; - if (url && url.scheme) { - return url; + NSURL *resolvedURL = resolveBundleSourceURL(jsBundleSourceNS); + if (resolvedURL) { + return resolvedURL; } - if ([jsBundleSourceNS hasSuffix:@".jsbundle"]) { - return [[NSBundle mainBundle] URLForResource:jsBundleSourceNS withExtension:nil]; - } + [BTLogger warn:[NSString stringWithFormat:@"Unable to resolve custom jsBundleSource=%@", jsBundleSourceNS]]; } - return [[NSBundle mainBundle] URLForResource: @"background" withExtension: @"bundle"]; -} - -- (void)postMessage:(const std::string &)message -{ - if (!_onMessageSandbox || !_rctInstance) { - return; + // Default: load common bundle (shared polyfills + modules). + // The background entry bundle is loaded later in hostDidStart:. + NSURL *commonURL = resolveMainBundleResourceURL(@"common.jsbundle"); + if (commonURL) { + return commonURL; } - - [_rctInstance callFunctionOnBufferedRuntimeExecutor:[=](jsi::Runtime &runtime) { - try { - // Validate runtime before any JSI operations - runtime.global(); // Test if runtime is accessible - - // Double-check the JSI function is still valid - if (!_onMessageSandbox) { - return; - } - - jsi::Value parsedValue = runtime.global() - .getPropertyAsObject(runtime, "JSON") - .getPropertyAsFunction(runtime, "parse") - .call(runtime, jsi::String::createFromUtf8(runtime, message)); - - _onMessageSandbox->call(runtime, {std::move(parsedValue)}); - } catch (const jsi::JSError &e) { - [BTLogger error:[NSString stringWithFormat:@"JSError during postMessage: %s", e.getMessage().c_str()]]; - } catch (const std::exception &e) { - [BTLogger error:[NSString stringWithFormat:@"RuntimeError during postMessage: %s", e.what()]]; - } catch (...) { - [BTLogger error:[NSString stringWithFormat:@"Runtime invalid during postMessage for sandbox %s", _origin.c_str()]]; - } - }]; + return [[NSBundle mainBundle] URLForResource:@"common" withExtension:@"jsbundle"]; } -- (bool)routeMessage:(const std::string &)message toSandbox:(const std::string &)targetId +- (NSString *)resolveBackgroundEntryBundlePath { - // Sandbox routing is not yet implemented. Deny all cross-sandbox messages by default. - [BTLogger warn:@"routeMessage denied: sandbox routing not implemented"]; - return false; + NSURL *url = resolveMainBundleResourceURL(@"background.bundle"); + return url.path; } - (void)hostDidStart:(RCTHost *)host @@ -179,11 +203,6 @@ - (void)hostDidStart:(RCTHost *)host return; } - // Safely clear any existing JSI function and instance before new runtime setup - // This prevents crash on reload when old function is tied to invalid runtime - _onMessageSandbox.reset(); - _onMessageSandbox = nullptr; - // Clear old instance reference before setting new one _rctInstance = nil; @@ -194,13 +213,91 @@ - (void)hostDidStart:(RCTHost *)host return; } + // When _jsBundleSource is set, the bundle loaded in bundleURL was already + // a full single bundle (dev mode / explicit override), so skip entry loading. + BOOL isSplitBundle = _jsBundleSource.empty(); + + // Read the background entry bundle data before entering the executor block + // (only needed in split-bundle mode). + NSData *bgBundleData = nil; + NSString *bgBundleSourceURL = nil; + if (isSplitBundle) { + NSString *bgBundlePath = [self resolveBackgroundEntryBundlePath]; + if (bgBundlePath) { + bgBundleData = [NSData dataWithContentsOfFile:bgBundlePath]; + bgBundleSourceURL = bgBundlePath.lastPathComponent ?: @"background.bundle"; + [BTLogger info:[NSString stringWithFormat:@"Background entry bundle loaded from %@ (%lu bytes)", + bgBundlePath, (unsigned long)bgBundleData.length]]; + } else { + [BTLogger warn:@"Background entry bundle not found, __setupBackgroundRPCHandler may not be defined"]; + } + } + + CFAbsoluteTime bgStartTime = CFAbsoluteTimeGetCurrent(); + [_rctInstance callFunctionOnBufferedRuntimeExecutor:[=](jsi::Runtime &runtime) { - facebook::react::defineReadOnlyGlobal(runtime, "postHostMessage", [self createPostMessageFunction:runtime]); - facebook::react::defineReadOnlyGlobal(runtime, "onHostMessage", [self createSetOnMessageFunction:runtime]); [self setupErrorHandler:runtime]; + + // Install SharedStore into background runtime + SharedStore::install(runtime); + + // Install SharedRPC with executor for cross-runtime notifications + RCTInstance *bgInstance = _rctInstance; + RPCRuntimeExecutor bgExecutor = [bgInstance](std::function work) { + [bgInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) { + work(rt); + }]; + }; + SharedRPC::install(runtime, std::move(bgExecutor), "background"); + [BTLogger info:@"SharedStore and SharedRPC installed in background runtime"]; + + // In split-bundle mode, evaluate the background entry bundle now. + // This must happen BEFORE invokeOptionalGlobalFunction since the entry + // bundle defines __setupBackgroundRPCHandler. + if (isSplitBundle && bgBundleData && bgBundleData.length > 0) { + CFAbsoluteTime bgEvalStart = CFAbsoluteTimeGetCurrent(); + auto buffer = std::make_shared( + std::string(static_cast(bgBundleData.bytes), bgBundleData.length)); + runtime.evaluateJavaScript(std::move(buffer), [bgBundleSourceURL UTF8String]); + double bgEvalMs = (CFAbsoluteTimeGetCurrent() - bgEvalStart) * 1000.0; + [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg entry evaluated in %.1fms", bgEvalMs]]; + } + + double bgTotalMs = (CFAbsoluteTimeGetCurrent() - bgStartTime) * 1000.0; + [BTLogger info:[NSString stringWithFormat:@"[SplitBundle] bg hostDidStart total setup in %.1fms", bgTotalMs]]; + + invokeOptionalGlobalFunction(runtime, "__setupBackgroundRPCHandler"); }]; } +#pragma mark - Segment Registration (Phase 2.5 spike) + +- (BOOL)registerSegmentWithId:(NSNumber *)segmentId path:(NSString *)path +{ + if (!_rctInstance) { + [BTLogger error:@"Cannot register segment: background RCTInstance not available"]; + return NO; + } + + @try { + SEL sel = NSSelectorFromString(@"registerSegmentWithId:path:"); + if ([_rctInstance respondsToSelector:sel]) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Warc-performSelector-leaks" + [_rctInstance performSelector:sel withObject:segmentId withObject:path]; +#pragma clang diagnostic pop + [BTLogger info:[NSString stringWithFormat:@"Segment registered in background runtime: id=%@, path=%@", segmentId, path]]; + return YES; + } else { + [BTLogger error:@"RCTInstance does not respond to registerSegmentWithId:path:"]; + return NO; + } + } @catch (NSException *exception) { + [BTLogger error:[NSString stringWithFormat:@"Failed to register segment: %@", exception.reason]]; + return NO; + } +} + #pragma mark - RCTTurboModuleManagerDelegate - (id)getModuleProvider:(const char *)name @@ -214,67 +311,6 @@ - (void)hostDidStart:(RCTHost *)host return [super getTurboModule:name jsInvoker:jsInvoker]; } -- (jsi::Function)createPostMessageFunction:(jsi::Runtime &)runtime -{ - return jsi::Function::createFromHostFunction( - runtime, - jsi::PropNameID::forAscii(runtime, "postMessage"), - 2, // Updated to accept up to 2 arguments - [=](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t count) { - // Validate runtime before any JSI operations - try { - rt.global(); // Test if runtime is accessible - } catch (...) { - return jsi::Value::undefined(); - } - - if (count < 1 || count > 2) { - throw jsi::JSError(rt, "Expected 1 or 2 arguments: postMessage(message, targetOrigin?)"); - } - - const jsi::Value &messageArg = args[0]; - if (!messageArg.isString()) { - throw jsi::JSError(rt, "Expected an string as the first argument"); - } -// jsi::Object jsonObject = rt.global().getPropertyAsObject(rt, "JSON"); -// jsi::Function jsonStringify = jsonObject.getPropertyAsFunction(rt, "stringify"); -// jsi::Value jsonResult = jsonStringify.call(rt, messageArg); - std::string messageJson = messageArg.getString(rt).utf8(rt); - NSString *messageNS = [NSString stringWithUTF8String:messageJson.c_str()]; - dispatch_async(dispatch_get_main_queue(), ^{ - if (self.onMessageCallback) { - self.onMessageCallback(messageNS); - } - }); - return jsi::Value::undefined(); - }); -} - -- (jsi::Function)createSetOnMessageFunction:(jsi::Runtime &)runtime -{ - return jsi::Function::createFromHostFunction( - runtime, - jsi::PropNameID::forAscii(runtime, "setOnMessage"), - 1, - [=](jsi::Runtime &rt, const jsi::Value &, const jsi::Value *args, size_t count) { - if (count != 1) { - throw jsi::JSError(rt, "Expected exactly one argument"); - } - - const jsi::Value &arg = args[0]; - if (!arg.isObject() || !arg.asObject(rt).isFunction(rt)) { - throw jsi::JSError(rt, "Expected a function as the first argument"); - } - - jsi::Function fn = arg.asObject(rt).asFunction(rt); - - _onMessageSandbox.reset(); - _onMessageSandbox = std::make_shared(std::move(fn)); - - return jsi::Value::undefined(); - }); -} - - (void)setupErrorHandler:(jsi::Runtime &)runtime { // Get ErrorUtils diff --git a/native-modules/react-native-background-thread/ios/BackgroundThread.h b/native-modules/react-native-background-thread/ios/BackgroundThread.h index 275a9e2f..c381d742 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThread.h +++ b/native-modules/react-native-background-thread/ios/BackgroundThread.h @@ -4,5 +4,10 @@ - (void)startBackgroundRunner; - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL; +- (void)installSharedBridge; +- (void)loadSegmentInBackground:(double)segmentId + path:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; @end diff --git a/native-modules/react-native-background-thread/ios/BackgroundThread.mm b/native-modules/react-native-background-thread/ios/BackgroundThread.mm index 81e51998..1d54b5cc 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThread.mm +++ b/native-modules/react-native-background-thread/ios/BackgroundThread.mm @@ -18,33 +18,33 @@ - (void)startBackgroundRunner { } - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL { - BackgroundThreadManager *manager = [BackgroundThreadManager sharedInstance]; + BackgroundThreadManager *manager = [BackgroundThreadManager sharedInstance]; [manager startBackgroundRunnerWithEntryURL:entryURL]; } -// Force register event callback during initialization -// This is mainly to handle the scenario of restarting in development environment -- (void)initBackgroundThread { - [self bindMessageCallback]; +- (void)installSharedBridge { + // On iOS, SharedBridge is installed from AppDelegate's hostDidStart: callback + // via +[BackgroundThreadManager installSharedBridgeInMainRuntime:]. + // This is a no-op here — kept for API parity with Android. + [BTLogger info:@"installSharedBridge called (no-op on iOS, installed from AppDelegate)"]; } -- (void)bindMessageCallback { +- (void)loadSegmentInBackground:(double)segmentId + path:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { BackgroundThreadManager *manager = [BackgroundThreadManager sharedInstance]; - __weak __typeof__(self) weakSelf = self; - [manager setOnMessageCallback:^(NSString *message) { - [weakSelf emitOnBackgroundMessage:message]; + [manager registerSegmentInBackground:@((int)segmentId) + path:path + completion:^(NSError * _Nullable error) { + if (error) { + reject(@"BG_SEGMENT_LOAD_ERROR", error.localizedDescription, error); + } else { + resolve(nil); + } }]; } -- (void)postBackgroundMessage:(nonnull NSString *)message { - BackgroundThreadManager *manager = [BackgroundThreadManager - sharedInstance]; - if (!manager.checkMessageCallback) { - [self bindMessageCallback]; - } - [[BackgroundThreadManager sharedInstance] postBackgroundMessage:message]; -} - + (NSString *)moduleName { return @"BackgroundThread"; diff --git a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h index fa0cf969..ea241c0a 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h +++ b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.h @@ -4,12 +4,18 @@ NS_ASSUME_NONNULL_BEGIN @class BackgroundReactNativeDelegate; @class RCTReactNativeFactory; +@class RCTHost; @interface BackgroundThreadManager : NSObject /// Shared instance for singleton pattern + (instancetype)sharedInstance; +/// Install SharedBridge HostObject into the main (UI) runtime. +/// Call this from your AppDelegate's ReactNativeDelegate hostDidStart: callback. +/// @param host The RCTHost for the main runtime ++ (void)installSharedBridgeInMainRuntime:(RCTHost *)host; + /// Start background runner with default entry URL - (void)startBackgroundRunner; @@ -17,21 +23,17 @@ NS_ASSUME_NONNULL_BEGIN /// @param entryURL The custom entry URL for the background runner - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL; -/// Post message to background runner -/// @param message The message to post -- (void)postBackgroundMessage:(NSString *)message; - -/// Set callback for handling background messages -/// @param callback The callback block to handle messages -- (void)setOnMessageCallback:(void (^)(NSString *message))callback; - -/// Check if message callback is set -/// @return YES if message callback is set, NO otherwise -- (BOOL)checkMessageCallback; - /// Check if background runner is started @property (nonatomic, readonly) BOOL isStarted; +/// Register a HBC segment in the background runtime (Phase 2.5 spike) +/// @param segmentId The segment ID to register +/// @param path Absolute file path to the .seg.hbc file +/// @param completion Callback with nil error on success, or NSError on failure +- (void)registerSegmentInBackground:(NSNumber *)segmentId + path:(NSString *)path + completion:(void (^)(NSError * _Nullable error))completion; + @end NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm index 16a19630..c525de27 100644 --- a/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm +++ b/native-modules/react-native-background-thread/ios/BackgroundThreadManager.mm @@ -15,12 +15,16 @@ #import "BackgroundRunnerReactNativeDelegate.h" #import "BTLogger.h" +#include "SharedStore.h" +#include "SharedRPC.h" +#import +#import + @interface BackgroundThreadManager () @property (nonatomic, strong) BackgroundReactNativeDelegate *reactNativeFactoryDelegate; @property (nonatomic, strong) RCTReactNativeFactory *reactNativeFactory; @property (nonatomic, assign) BOOL hasListeners; @property (nonatomic, assign, readwrite) BOOL isStarted; -@property (nonatomic, copy) void (^onMessageCallback)(NSString *message); @end @implementation BackgroundThreadManager @@ -48,6 +52,37 @@ - (instancetype)init { return self; } +#pragma mark - SharedBridge + ++ (void)installSharedBridgeInMainRuntime:(RCTHost *)host { + if (!host) { + [BTLogger error:@"Cannot install SharedBridge: RCTHost is nil"]; + return; + } + + Ivar ivar = class_getInstanceVariable([host class], "_instance"); + id instance = object_getIvar(host, ivar); + + if (!instance) { + [BTLogger error:@"Cannot install SharedBridge: RCTInstance is nil"]; + return; + } + + [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) { + SharedStore::install(runtime); + + // Install SharedRPC with executor for cross-runtime notifications + id capturedInstance = instance; + RPCRuntimeExecutor mainExecutor = [capturedInstance](std::function work) { + [capturedInstance callFunctionOnBufferedRuntimeExecutor:[work](jsi::Runtime &rt) { + work(rt); + }]; + }; + SharedRPC::install(runtime, std::move(mainExecutor), "main"); + [BTLogger info:@"SharedStore and SharedRPC installed in main runtime"]; + }]; +} + #pragma mark - Public Methods - (void)startBackgroundRunner { @@ -71,39 +106,49 @@ - (void)startBackgroundRunnerWithEntryURL:(NSString *)entryURL { NSDictionary *launchOptions = @{}; self.reactNativeFactoryDelegate = [[BackgroundReactNativeDelegate alloc] init]; self.reactNativeFactory = [[RCTReactNativeFactory alloc] initWithDelegate:self.reactNativeFactoryDelegate]; - - #if DEBUG - [self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])]; - #endif - + + [self.reactNativeFactoryDelegate setJsBundleSource:std::string([entryURL UTF8String])]; + [self.reactNativeFactory.rootViewFactory viewWithModuleName:MODULE_NAME initialProperties:initialProperties launchOptions:launchOptions]; - - __weak __typeof__(self) weakSelf = self; - [self.reactNativeFactoryDelegate setOnMessageCallback:^(NSString *message) { - if (weakSelf.onMessageCallback) { - weakSelf.onMessageCallback(message); - } - }]; }); } -- (void)postBackgroundMessage:(NSString *)message { - if (self.reactNativeFactoryDelegate) { - [self.reactNativeFactoryDelegate postMessage:std::string([message UTF8String])]; +#pragma mark - Segment Registration (Phase 2.5 spike) + +- (void)registerSegmentInBackground:(NSNumber *)segmentId + path:(NSString *)path + completion:(void (^)(NSError * _Nullable error))completion +{ + if (!self.isStarted || !self.reactNativeFactoryDelegate) { + NSError *error = [NSError errorWithDomain:@"BackgroundThread" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"Background runtime not started"}]; + if (completion) completion(error); + return; } -} -- (void)setOnMessageCallback:(void (^)(NSString *message))callback { - _onMessageCallback = callback; -} + // Verify the file exists + if (![[NSFileManager defaultManager] fileExistsAtPath:path]) { + NSError *error = [NSError errorWithDomain:@"BackgroundThread" + code:2 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Segment file not found: %@", path]}]; + if (completion) completion(error); + return; + } -- (BOOL)checkMessageCallback { - if (self.onMessageCallback) { - return YES; + BOOL success = [self.reactNativeFactoryDelegate registerSegmentWithId:segmentId path:path]; + if (success) { + if (completion) completion(nil); + } else { + NSError *error = [NSError errorWithDomain:@"BackgroundThread" + code:3 + userInfo:@{NSLocalizedDescriptionKey: + @"Failed to register segment in background runtime"}]; + if (completion) completion(error); } - return NO; } @end diff --git a/native-modules/react-native-background-thread/package.json b/native-modules/react-native-background-thread/package.json index c8cf1520..0ecab15f 100644 --- a/native-modules/react-native-background-thread/package.json +++ b/native-modules/react-native-background-thread/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-background-thread", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-background-thread", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -29,7 +29,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -76,7 +77,7 @@ "lefthook": "^2.0.3", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.17", "release-it": "^19.0.4", "turbo": "^2.5.6", @@ -110,6 +111,11 @@ "jsSrcsDir": "src", "android": { "javaPackageName": "com.backgroundthread" + }, + "ios": { + "modulesProvider": { + "BackgroundThread": "BackgroundThread" + } } }, "prettier": { diff --git a/native-modules/react-native-background-thread/src/NativeBackgroundThread.ts b/native-modules/react-native-background-thread/src/NativeBackgroundThread.ts index 63ae5c19..e97ba60a 100644 --- a/native-modules/react-native-background-thread/src/NativeBackgroundThread.ts +++ b/native-modules/react-native-background-thread/src/NativeBackgroundThread.ts @@ -1,11 +1,13 @@ import { TurboModuleRegistry } from 'react-native'; -import type { CodegenTypes, TurboModule } from 'react-native'; +import type { TurboModule } from 'react-native'; export interface Spec extends TurboModule { - readonly onBackgroundMessage: CodegenTypes.EventEmitter; - postBackgroundMessage(message: string): void; startBackgroundRunnerWithEntryURL(entryURL: string): void; - initBackgroundThread(): void; + installSharedBridge(): void; + loadSegmentInBackground( + segmentId: number, + path: string, + ): Promise; } export default TurboModuleRegistry.getEnforcing('BackgroundThread'); diff --git a/native-modules/react-native-background-thread/src/SharedRPC.ts b/native-modules/react-native-background-thread/src/SharedRPC.ts new file mode 100644 index 00000000..e30afde1 --- /dev/null +++ b/native-modules/react-native-background-thread/src/SharedRPC.ts @@ -0,0 +1,16 @@ +export interface ISharedRPC { + write(callId: string, value: string | number | boolean): void; + read(callId: string): string | number | boolean | undefined; + has(callId: string): boolean; + readonly pendingCount: number; + onWrite(callback: (callId: string) => void): void; +} + +declare global { + // eslint-disable-next-line no-var + var sharedRPC: ISharedRPC | undefined; +} + +export function getSharedRPC(): ISharedRPC | undefined { + return globalThis.sharedRPC; +} diff --git a/native-modules/react-native-background-thread/src/SharedStore.ts b/native-modules/react-native-background-thread/src/SharedStore.ts new file mode 100644 index 00000000..9263dc6d --- /dev/null +++ b/native-modules/react-native-background-thread/src/SharedStore.ts @@ -0,0 +1,18 @@ +export interface ISharedStore { + set(key: string, value: string | number | boolean): void; + get(key: string): string | number | boolean | undefined; + has(key: string): boolean; + delete(key: string): boolean; + keys(): string[]; + clear(): void; + readonly size: number; +} + +declare global { + // eslint-disable-next-line no-var + var sharedStore: ISharedStore | undefined; +} + +export function getSharedStore(): ISharedStore | undefined { + return globalThis.sharedStore; +} diff --git a/native-modules/react-native-background-thread/src/index.tsx b/native-modules/react-native-background-thread/src/index.tsx index 9eb79b52..889f2712 100644 --- a/native-modules/react-native-background-thread/src/index.tsx +++ b/native-modules/react-native-background-thread/src/index.tsx @@ -1,3 +1,7 @@ import NativeBackgroundThread from './NativeBackgroundThread'; export const BackgroundThread = NativeBackgroundThread; +export { getSharedStore } from './SharedStore'; +export type { ISharedStore } from './SharedStore'; +export { getSharedRPC } from './SharedRPC'; +export type { ISharedRPC } from './SharedRPC'; diff --git a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt index da55c424..4907ccdd 100644 --- a/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt +++ b/native-modules/react-native-bundle-update/android/src/main/java/com/margelo/nitro/reactnativebundleupdate/ReactNativeBundleUpdate.kt @@ -64,31 +64,43 @@ fpqe0vKYUlT092joT0o6nT2MzmLmHUW0kDqD9p6JEJEZUZpqcSRE84eMTFNyu966 xy/rjN2SMJTFzkNXPkwXYrMYoahGez1oZfLzV6SQ0+blNc3aATt9aQW6uaCZtMw1 ibcfWW9neHVpRtTlMYCoa2reGaBGCv0Nd8pMcyFUQkVaes5cQHkh3r5Dba+YrVvp l4P8HMbN8/LqAv7eBfj3ylPa/8eEPWVifcum2Y9TqherN1C2JDqWIpH4EsApek3k -NMK6q0lPxXjZ3Pa5Ag0EYkBMbAEQAM1R4N3bBkwKkHeYwsQASevUkHwY4eg6Ncgp -f9NbmJHcEioqXTIv0nHCQbos3P2NhXvDowj4JFkK/ZbpP9yo0p7TI4fckseVSWwI -tiF9l/8OmXvYZMtw3hHcUUZVdJnk0xrqT6ni6hyRFIfbqous6/vpqi0GG7nB/+lU -E5StGN8696ZWRyAX9MmwoRoods3ShNJP0+GCYHfIcG0XRhEDMJph+7mWPlkQUcza -4aEjxOQ4Stwwp+ZL1rXSlyJIPk1S9/FIS/Uw5GgqFJXIf5n+SCVtUZ8lGedEWwe4 -wXsoPFxxOc2Gqw5r4TrJFdgA3MptYebXmb2LGMssXQTM1AQS2LdpnWw44+X1CHvQ -0m4pEw/g2OgeoJPBurVUnu2mU/M+ARZiS4ceAR0pLZN7Yq48p1wr6EOBQdA3Usby -uc17MORG/IjRmjz4SK/luQLXjN+0jwQSoM1kcIHoRk37B8feHjVufJDKlqtw83H1 -uNu6lGwb8MxDgTuuHloDijCDQsn6m7ZKU1qqLDGtdvCUY2ovzuOUS9vv6MAhR86J -kqoU3sOBMeQhnBaTNKU0IjT4M+ERCWQ7MewlzXuPHgyb4xow1SKZny+f+fYXPy9+ -hx4/j5xaKrZKdq5zIo+GRGe4lA088l253nGeLgSnXsbSxqADqKK73d7BXLCVEZHx -f4Sa5JN7ABEBAAGJAjwEGAEIACYWIQTraK5UTx/djNJkYk+zaaZ6kL84ewUCYkBM -bAIbDAUJB4YfRAAKCRCzaaZ6kL84e0UGD/4mVWyGoQC86TyPoU4Pb5r8mynXWmiH -ZGKu2ll8qn3l5Q67OophgbA1I0GTBFsYK2f91ahgs7FEsLrmz/25E8ybcdJipITE -6869nyE1b37jVb3z3BJLYS/4MaNvugNz4VjMHWVAL52glXLN+SJBSNscmWZDKnVn -Rnrn+kBEvOWZgLbi4MpPiNVwm2PGnrtPzudTcg/NS3HOcmJTfG3mrnwwNJybTVAx -txlQPoXUpJQqJjtkPPW+CqosolpRdugQ5zpFSg05iL+vN+CMrVPkk85w87dtsidl -yZl/ZNITrLzym9d2UFVQZY2rRohNdRfx3l4rfXJFLaqQtihRvBIiMKTbUb2V0pd3 -rVLz2Ck3gJqPfPEEmCWS0Nx6rME8m0sOkNyMau3dMUUAs4j2c3pOQmsZRjKo7LAc -7/GahKFhZ2aBCQzvcTES+gPH1Z5HnivkcnUF2gnQV9x7UOr1Q/euKJsxPl5CCZtM -N9GFW10cDxFo7cO5Ch+/BkkkfebuI/4Wa1SQTzawsxTx4eikKwcemgfDsyIqRs2W -62PBrqCzs9Tg19l35sCdmvYsvMadrYFXukHXiUKEpwJMdTLAtjJ+AX84YLwuHi3+ -qZ5okRCqZH+QpSojSScT9H5ze4ZpuP0d8pKycxb8M2RfYdyOtT/eqsZ/1EQPg7kq -P2Q5dClenjjjVA== -=F0np +NMK6q0lPxXjZ3PaJAlQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AW +IQTraK5UTx/djNJkYk+zaaZ6kL84ewUCactdeAUJDxpqwwAKCRCzaaZ6kL84e8TX +EACtuZUT79PZx964iUf6T04IZ/SFqftMdIPrvCOpyYUkzFfTjufZSP7S5dmut/dl +VLQnPjip0ZGeHeSX2ersXmmp7Ny2zqZr858ZIdLpamkEg6hRi5LWOOK4clnKzTLe +OGWlA6WzF3cb4YB4NiNOX1yxxtggZrndyMxLfSU27aZ4h98/g5j/o/FRCt0OzibH +IGKl+tUayKEEtq7+CrxWHwCXY+wFeeJFm2yhEMqeAZlVpsvGgtfWevQwHaRcld99 +5ousZOOqsCkl1J7rCeaIFowIEA3TzH0FWIQGahGiHN/+zwc7iSIL9gNEq4/AYJWK +80jPqyrRDia7VfZA/SULbWaPmmqrn/Y8qYl3jDvT/6BuwXFAgK9pz5NkWggkjAMX +nGylez9tZBfv+Bymv5RTRAHey49noF/6ZcF5fidtXAS2tfhuRIlOUfEY+QyB3lXj +kxeOOAGJ2ejTVBVIJnfoSFSsG+LH1tvzbDJvNQcMh0oQD849fip+6O0Ae3KfNZpw +aNkIdxThvBU0XCPgmyEXll/mkS5QlUQUo+EwbZOjr6xGmi310DgJo3Ry1dfZ8qBq +F3DD6NK40bkfw8I6Qjwf/IXd921ZbKe88UMjVBTpm2IH3WXR51My9LN/2gzV9zL+ +7odaaXfd+u2x9RuZ1caLXSv4Qyc/7Le1d2T4LpevA7GwMrkCDQRiQExsARAAzVHg +3dsGTAqQd5jCxABJ69SQfBjh6Do1yCl/01uYkdwSKipdMi/SccJBuizc/Y2Fe8Oj +CPgkWQr9luk/3KjSntMjh9ySx5VJbAi2IX2X/w6Ze9hky3DeEdxRRlV0meTTGupP +qeLqHJEUh9uqi6zr++mqLQYbucH/6VQTlK0Y3zr3plZHIBf0ybChGih2zdKE0k/T +4YJgd8hwbRdGEQMwmmH7uZY+WRBRzNrhoSPE5DhK3DCn5kvWtdKXIkg+TVL38UhL +9TDkaCoUlch/mf5IJW1RnyUZ50RbB7jBeyg8XHE5zYarDmvhOskV2ADcym1h5teZ +vYsYyyxdBMzUBBLYt2mdbDjj5fUIe9DSbikTD+DY6B6gk8G6tVSe7aZT8z4BFmJL +hx4BHSktk3tirjynXCvoQ4FB0DdSxvK5zXsw5Eb8iNGaPPhIr+W5AteM37SPBBKg +zWRwgehGTfsHx94eNW58kMqWq3DzcfW427qUbBvwzEOBO64eWgOKMINCyfqbtkpT +WqosMa128JRjai/O45RL2+/owCFHzomSqhTew4Ex5CGcFpM0pTQiNPgz4REJZDsx +7CXNe48eDJvjGjDVIpmfL5/59hc/L36HHj+PnFoqtkp2rnMij4ZEZ7iUDTzyXbne +cZ4uBKdextLGoAOoorvd3sFcsJURkfF/hJrkk3sAEQEAAYkCPAQYAQgAJhYhBOto +rlRPH92M0mRiT7NppnqQvzh7BQJiQExsAhsMBQkHhh9EAAoJELNppnqQvzh7RQYP +/iZVbIahALzpPI+hTg9vmvybKddaaIdkYq7aWXyqfeXlDrs6imGBsDUjQZMEWxgr +Z/3VqGCzsUSwuubP/bkTzJtx0mKkhMTrzr2fITVvfuNVvfPcEkthL/gxo2+6A3Ph +WMwdZUAvnaCVcs35IkFI2xyZZkMqdWdGeuf6QES85ZmAtuLgyk+I1XCbY8aeu0/O +51NyD81Lcc5yYlN8beaufDA0nJtNUDG3GVA+hdSklComO2Q89b4KqiyiWlF26BDn +OkVKDTmIv6834IytU+STznDzt22yJ2XJmX9k0hOsvPKb13ZQVVBljatGiE11F/He +Xit9ckUtqpC2KFG8EiIwpNtRvZXSl3etUvPYKTeAmo988QSYJZLQ3HqswTybSw6Q +3Ixq7d0xRQCziPZzek5CaxlGMqjssBzv8ZqEoWFnZoEJDO9xMRL6A8fVnkeeK+Ry +dQXaCdBX3HtQ6vVD964omzE+XkIJm0w30YVbXRwPEWjtw7kKH78GSSR95u4j/hZr +VJBPNrCzFPHh6KQrBx6aB8OzIipGzZbrY8GuoLOz1ODX2XfmwJ2a9iy8xp2tgVe6 +QdeJQoSnAkx1MsC2Mn4BfzhgvC4eLf6pnmiREKpkf5ClKiNJJxP0fnN7hmm4/R3y +krJzFvwzZF9h3I61P96qxn/URA+DuSo/ZDl0KV6eOONU +=HlTQ -----END PGP PUBLIC KEY BLOCK-----""" // Public static store for CustomReactNativeHost access (called before JS starts) @@ -97,6 +109,12 @@ object BundleUpdateStoreAndroid { private const val PREFS_NAME = "BundleUpdatePrefs" internal const val NATIVE_VERSION_PREFS_NAME = "NativeVersionPrefs" private const val CURRENT_BUNDLE_VERSION_KEY = "currentBundleVersion" + private const val MAIN_JS_BUNDLE_FILE_NAME = "main.jsbundle.hbc" + private const val BACKGROUND_BUNDLE_FILE_NAME = "background.bundle" + private const val COMMON_BUNDLE_FILE_NAME = "common.bundle" + private const val METADATA_REQUIRES_BACKGROUND_BUNDLE_KEY = "requiresBackgroundBundle" + private const val METADATA_BACKGROUND_PROTOCOL_VERSION_KEY = "backgroundProtocolVersion" + private const val SUPPORTED_BACKGROUND_PROTOCOL_VERSION = "1" fun getDownloadBundleDir(context: Context): String { val dir = File(context.filesDir, "onekey-bundle-download") @@ -401,6 +419,26 @@ object BundleUpdateStoreAndroid { return metadata } + private fun isReservedMetadataKey(key: String): Boolean { + return key == METADATA_REQUIRES_BACKGROUND_BUNDLE_KEY || + key == METADATA_BACKGROUND_PROTOCOL_VERSION_KEY + } + + private fun getFileMetadataEntries(metadata: Map): Map { + return metadata.filterKeys { key -> !isReservedMetadataKey(key) } + } + + private fun metadataRequiresBackgroundBundle(metadata: Map): Boolean { + return metadata[METADATA_REQUIRES_BACKGROUND_BUNDLE_KEY] + ?.lowercase() + ?.let { value -> value == "1" || value == "true" || value == "yes" } + ?: false + } + + private fun metadataBackgroundProtocolVersion(metadata: Map): String { + return metadata[METADATA_BACKGROUND_PROTOCOL_VERSION_KEY] ?: "" + } + fun readMetadataFileSha256(signature: String?): String? { if (signature.isNullOrEmpty()) return null @@ -534,11 +572,12 @@ object BundleUpdateStoreAndroid { val parentBundleDir = getBundleDir(context) val folderName = "$appVersion-$bundleVersion" val jsBundleDir = File(parentBundleDir, folderName).absolutePath + "/" + val fileEntries = getFileMetadataEntries(metadata) - if (!validateFilesRecursive(dir, metadata, jsBundleDir)) return false + if (!validateFilesRecursive(dir, fileEntries, jsBundleDir)) return false // Verify completeness - for (entry in metadata.entries) { + for (entry in fileEntries.entries) { val expectedFile = File(jsBundleDir + entry.key) if (!expectedFile.exists()) { OneKeyLog.error("BundleUpdate", "[bundle-verify] File listed in metadata but missing on disk: ${entry.key}") @@ -637,7 +676,48 @@ object BundleUpdateStoreAndroid { } } - fun getCurrentBundleMainJSBundle(context: Context): String? { + fun validateBundlePairCompatibility(bundleDir: String, metadata: Map): Boolean { + val mainBundleFile = File(bundleDir, MAIN_JS_BUNDLE_FILE_NAME) + if (!mainBundleFile.exists()) { + OneKeyLog.error( + "BundleUpdate", + "bundle pair invalid: main.jsbundle.hbc is missing at ${mainBundleFile.absolutePath}", + ) + return false + } + + if (!metadataRequiresBackgroundBundle(metadata)) { + return true + } + + val protocolVersion = metadataBackgroundProtocolVersion(metadata) + if (protocolVersion.isEmpty() || protocolVersion != SUPPORTED_BACKGROUND_PROTOCOL_VERSION) { + OneKeyLog.error( + "BundleUpdate", + "backgroundProtocolVersion mismatch: expected=$SUPPORTED_BACKGROUND_PROTOCOL_VERSION, actual=$protocolVersion", + ) + return false + } + + val backgroundBundleFile = File(bundleDir, BACKGROUND_BUNDLE_FILE_NAME) + if (!backgroundBundleFile.exists()) { + OneKeyLog.error( + "BundleUpdate", + "requiresBackgroundBundle is true but background.bundle is missing at ${backgroundBundleFile.absolutePath}", + ) + return false + } + + return true + } + + private data class ValidatedBundleInfo( + val bundleDir: String, + val currentBundleVersion: String, + val metadata: Map, + ) + + private fun getValidatedCurrentBundleInfo(context: Context): ValidatedBundleInfo? { processPreLaunchPendingTask(context) return try { val currentAppVersion = getAppVersion(context) @@ -651,7 +731,7 @@ object BundleUpdateStoreAndroid { val prevNativeVersion = getNativeVersion(context) if (prevNativeVersion.isEmpty()) { OneKeyLog.warn("BundleUpdate", "getJsBundlePath: prevNativeVersion is empty") - return "" + return null } if (currentAppVersion != prevNativeVersion) { @@ -707,23 +787,46 @@ object BundleUpdateStoreAndroid { } } - val mainJSBundleFile = File(bundleDir, "main.jsbundle.hbc") - val mainJSBundlePath = mainJSBundleFile.absolutePath - OneKeyLog.info("BundleUpdate", "mainJSBundlePath: $mainJSBundlePath") - if (!mainJSBundleFile.exists()) { - OneKeyLog.info("BundleUpdate", "mainJSBundleFile does not exist") + if (!validateBundlePairCompatibility(bundleDir, metadata)) { return null } - mainJSBundlePath + + ValidatedBundleInfo( + bundleDir = bundleDir, + currentBundleVersion = currentBundleVersion, + metadata = metadata, + ) } catch (e: Exception) { OneKeyLog.error("BundleUpdate", "Error getting bundle: ${e.message}") null } } + fun getCurrentBundleEntryPath(context: Context, entryFileName: String): String? { + val bundleInfo = getValidatedCurrentBundleInfo(context) ?: return null + val entryFile = File(bundleInfo.bundleDir, entryFileName) + if (!entryFile.exists()) { + OneKeyLog.info("BundleUpdate", "$entryFileName does not exist") + return null + } + return entryFile.absolutePath + } + + fun getCurrentBundleMainJSBundle(context: Context): String? { + return getCurrentBundleEntryPath(context, MAIN_JS_BUNDLE_FILE_NAME) + } + + fun getCurrentBundleBackgroundJSBundle(context: Context): String? { + return getCurrentBundleEntryPath(context, BACKGROUND_BUNDLE_FILE_NAME) + } + + fun getCurrentBundleCommonJSBundle(context: Context): String? { + return getCurrentBundleEntryPath(context, COMMON_BUNDLE_FILE_NAME) + } + fun getWebEmbedPath(context: Context): String { - val currentBundleDir = getCurrentBundleDir(context, getCurrentBundleVersion(context)) ?: return "" - return File(currentBundleDir, "web-embed").absolutePath + val bundleInfo = getValidatedCurrentBundleInfo(context) ?: return "" + return File(bundleInfo.bundleDir, "web-embed").absolutePath } /** @@ -1098,6 +1201,11 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { BundleUpdateStoreAndroid.deleteDir(destinationDir) throw Exception("Extracted files verification against metadata failed") } + if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(destination, metadata)) { + OneKeyLog.error("BundleUpdate", "verifyBundleASC: bundle pair compatibility check failed") + BundleUpdateStoreAndroid.deleteDir(destinationDir) + throw Exception("Bundle pair compatibility check failed") + } OneKeyLog.info("BundleUpdate", "verifyBundleASC: all verifications passed, appVersion=$appVersion, bundleVersion=$bundleVersion") } catch (e: Exception) { @@ -1366,6 +1474,22 @@ class ReactNativeBundleUpdate : HybridReactNativeBundleUpdateSpec() { } } + override fun getBackgroundJsBundlePath(): String { + val context = NitroModules.applicationContext ?: return "" + val path = BundleUpdateStoreAndroid.getCurrentBundleBackgroundJSBundle(context) ?: "" + OneKeyLog.debug("BundleUpdate", "getBackgroundJsBundlePath: ${if (path.isEmpty()) "(empty/no bundle)" else path}") + return path + } + + override fun getBackgroundJsBundlePathAsync(): Promise { + return Promise.async { + val context = getContext() + val path = BundleUpdateStoreAndroid.getCurrentBundleBackgroundJSBundle(context) ?: "" + OneKeyLog.info("BundleUpdate", "getBackgroundJsBundlePathAsync: ${if (path.isEmpty()) "(empty/no bundle)" else path}") + path + } + } + override fun getNativeAppVersion(): Promise { return Promise.async { val context = getContext() @@ -1480,6 +1604,10 @@ n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: file integrity check failed") throw Exception("File integrity check failed") } + if (!BundleUpdateStoreAndroid.validateBundlePairCompatibility(bundlePath.absolutePath, metadata)) { + OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle pair compatibility check failed") + throw Exception("Bundle pair compatibility check failed") + } OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: all files verified OK, fileCount=${metadata.size}") } } diff --git a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift index da4a77de..adcdc135 100644 --- a/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift +++ b/native-modules/react-native-bundle-update/ios/ReactNativeBundleUpdate.swift @@ -34,31 +34,43 @@ fpqe0vKYUlT092joT0o6nT2MzmLmHUW0kDqD9p6JEJEZUZpqcSRE84eMTFNyu966 xy/rjN2SMJTFzkNXPkwXYrMYoahGez1oZfLzV6SQ0+blNc3aATt9aQW6uaCZtMw1 ibcfWW9neHVpRtTlMYCoa2reGaBGCv0Nd8pMcyFUQkVaes5cQHkh3r5Dba+YrVvp l4P8HMbN8/LqAv7eBfj3ylPa/8eEPWVifcum2Y9TqherN1C2JDqWIpH4EsApek3k -NMK6q0lPxXjZ3Pa5Ag0EYkBMbAEQAM1R4N3bBkwKkHeYwsQASevUkHwY4eg6Ncgp -f9NbmJHcEioqXTIv0nHCQbos3P2NhXvDowj4JFkK/ZbpP9yo0p7TI4fckseVSWwI -tiF9l/8OmXvYZMtw3hHcUUZVdJnk0xrqT6ni6hyRFIfbqous6/vpqi0GG7nB/+lU -E5StGN8696ZWRyAX9MmwoRoods3ShNJP0+GCYHfIcG0XRhEDMJph+7mWPlkQUcza -4aEjxOQ4Stwwp+ZL1rXSlyJIPk1S9/FIS/Uw5GgqFJXIf5n+SCVtUZ8lGedEWwe4 -wXsoPFxxOc2Gqw5r4TrJFdgA3MptYebXmb2LGMssXQTM1AQS2LdpnWw44+X1CHvQ -0m4pEw/g2OgeoJPBurVUnu2mU/M+ARZiS4ceAR0pLZN7Yq48p1wr6EOBQdA3Usby -uc17MORG/IjRmjz4SK/luQLXjN+0jwQSoM1kcIHoRk37B8feHjVufJDKlqtw83H1 -uNu6lGwb8MxDgTuuHloDijCDQsn6m7ZKU1qqLDGtdvCUY2ovzuOUS9vv6MAhR86J -kqoU3sOBMeQhnBaTNKU0IjT4M+ERCWQ7MewlzXuPHgyb4xow1SKZny+f+fYXPy9+ -hx4/j5xaKrZKdq5zIo+GRGe4lA088l253nGeLgSnXsbSxqADqKK73d7BXLCVEZHx -f4Sa5JN7ABEBAAGJAjwEGAEIACYWIQTraK5UTx/djNJkYk+zaaZ6kL84ewUCYkBM -bAIbDAUJB4YfRAAKCRCzaaZ6kL84e0UGD/4mVWyGoQC86TyPoU4Pb5r8mynXWmiH -ZGKu2ll8qn3l5Q67OophgbA1I0GTBFsYK2f91ahgs7FEsLrmz/25E8ybcdJipITE -6869nyE1b37jVb3z3BJLYS/4MaNvugNz4VjMHWVAL52glXLN+SJBSNscmWZDKnVn -Rnrn+kBEvOWZgLbi4MpPiNVwm2PGnrtPzudTcg/NS3HOcmJTfG3mrnwwNJybTVAx -txlQPoXUpJQqJjtkPPW+CqosolpRdugQ5zpFSg05iL+vN+CMrVPkk85w87dtsidl -yZl/ZNITrLzym9d2UFVQZY2rRohNdRfx3l4rfXJFLaqQtihRvBIiMKTbUb2V0pd3 -rVLz2Ck3gJqPfPEEmCWS0Nx6rME8m0sOkNyMau3dMUUAs4j2c3pOQmsZRjKo7LAc -7/GahKFhZ2aBCQzvcTES+gPH1Z5HnivkcnUF2gnQV9x7UOr1Q/euKJsxPl5CCZtM -N9GFW10cDxFo7cO5Ch+/BkkkfebuI/4Wa1SQTzawsxTx4eikKwcemgfDsyIqRs2W -62PBrqCzs9Tg19l35sCdmvYsvMadrYFXukHXiUKEpwJMdTLAtjJ+AX84YLwuHi3+ -qZ5okRCqZH+QpSojSScT9H5ze4ZpuP0d8pKycxb8M2RfYdyOtT/eqsZ/1EQPg7kq -P2Q5dClenjjjVA== -=F0np +NMK6q0lPxXjZ3PaJAlQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AW +IQTraK5UTx/djNJkYk+zaaZ6kL84ewUCactdeAUJDxpqwwAKCRCzaaZ6kL84e8TX +EACtuZUT79PZx964iUf6T04IZ/SFqftMdIPrvCOpyYUkzFfTjufZSP7S5dmut/dl +VLQnPjip0ZGeHeSX2ersXmmp7Ny2zqZr858ZIdLpamkEg6hRi5LWOOK4clnKzTLe +OGWlA6WzF3cb4YB4NiNOX1yxxtggZrndyMxLfSU27aZ4h98/g5j/o/FRCt0OzibH +IGKl+tUayKEEtq7+CrxWHwCXY+wFeeJFm2yhEMqeAZlVpsvGgtfWevQwHaRcld99 +5ousZOOqsCkl1J7rCeaIFowIEA3TzH0FWIQGahGiHN/+zwc7iSIL9gNEq4/AYJWK +80jPqyrRDia7VfZA/SULbWaPmmqrn/Y8qYl3jDvT/6BuwXFAgK9pz5NkWggkjAMX +nGylez9tZBfv+Bymv5RTRAHey49noF/6ZcF5fidtXAS2tfhuRIlOUfEY+QyB3lXj +kxeOOAGJ2ejTVBVIJnfoSFSsG+LH1tvzbDJvNQcMh0oQD849fip+6O0Ae3KfNZpw +aNkIdxThvBU0XCPgmyEXll/mkS5QlUQUo+EwbZOjr6xGmi310DgJo3Ry1dfZ8qBq +F3DD6NK40bkfw8I6Qjwf/IXd921ZbKe88UMjVBTpm2IH3WXR51My9LN/2gzV9zL+ +7odaaXfd+u2x9RuZ1caLXSv4Qyc/7Le1d2T4LpevA7GwMrkCDQRiQExsARAAzVHg +3dsGTAqQd5jCxABJ69SQfBjh6Do1yCl/01uYkdwSKipdMi/SccJBuizc/Y2Fe8Oj +CPgkWQr9luk/3KjSntMjh9ySx5VJbAi2IX2X/w6Ze9hky3DeEdxRRlV0meTTGupP +qeLqHJEUh9uqi6zr++mqLQYbucH/6VQTlK0Y3zr3plZHIBf0ybChGih2zdKE0k/T +4YJgd8hwbRdGEQMwmmH7uZY+WRBRzNrhoSPE5DhK3DCn5kvWtdKXIkg+TVL38UhL +9TDkaCoUlch/mf5IJW1RnyUZ50RbB7jBeyg8XHE5zYarDmvhOskV2ADcym1h5teZ +vYsYyyxdBMzUBBLYt2mdbDjj5fUIe9DSbikTD+DY6B6gk8G6tVSe7aZT8z4BFmJL +hx4BHSktk3tirjynXCvoQ4FB0DdSxvK5zXsw5Eb8iNGaPPhIr+W5AteM37SPBBKg +zWRwgehGTfsHx94eNW58kMqWq3DzcfW427qUbBvwzEOBO64eWgOKMINCyfqbtkpT +WqosMa128JRjai/O45RL2+/owCFHzomSqhTew4Ex5CGcFpM0pTQiNPgz4REJZDsx +7CXNe48eDJvjGjDVIpmfL5/59hc/L36HHj+PnFoqtkp2rnMij4ZEZ7iUDTzyXbne +cZ4uBKdextLGoAOoorvd3sFcsJURkfF/hJrkk3sAEQEAAYkCPAQYAQgAJhYhBOto +rlRPH92M0mRiT7NppnqQvzh7BQJiQExsAhsMBQkHhh9EAAoJELNppnqQvzh7RQYP +/iZVbIahALzpPI+hTg9vmvybKddaaIdkYq7aWXyqfeXlDrs6imGBsDUjQZMEWxgr +Z/3VqGCzsUSwuubP/bkTzJtx0mKkhMTrzr2fITVvfuNVvfPcEkthL/gxo2+6A3Ph +WMwdZUAvnaCVcs35IkFI2xyZZkMqdWdGeuf6QES85ZmAtuLgyk+I1XCbY8aeu0/O +51NyD81Lcc5yYlN8beaufDA0nJtNUDG3GVA+hdSklComO2Q89b4KqiyiWlF26BDn +OkVKDTmIv6834IytU+STznDzt22yJ2XJmX9k0hOsvPKb13ZQVVBljatGiE11F/He +Xit9ckUtqpC2KFG8EiIwpNtRvZXSl3etUvPYKTeAmo988QSYJZLQ3HqswTybSw6Q +3Ixq7d0xRQCziPZzek5CaxlGMqjssBzv8ZqEoWFnZoEJDO9xMRL6A8fVnkeeK+Ry +dQXaCdBX3HtQ6vVD964omzE+XkIJm0w30YVbXRwPEWjtw7kKH78GSSR95u4j/hZr +VJBPNrCzFPHh6KQrBx6aB8OzIipGzZbrY8GuoLOz1ODX2XfmwJ2a9iy8xp2tgVe6 +QdeJQoSnAkx1MsC2Mn4BfzhgvC4eLf6pnmiREKpkf5ClKiNJJxP0fnN7hmm4/R3y +krJzFvwzZF9h3I61P96qxn/URA+DuSo/ZDl0KV6eOONU +=HlTQ -----END PGP PUBLIC KEY BLOCK----- """ @@ -68,6 +80,11 @@ public class BundleUpdateStore: NSObject { private static let bundlePrefsKey = "currentBundleVersion" private static let nativeVersionKey = "nativeVersion" private static let nativeBuildNumberKey = "nativeBuildNumber" + private static let mainBundleEntryFileName = "main.jsbundle.hbc" + private static let backgroundBundleEntryFileName = "background.bundle" + private static let metadataRequiresBackgroundBundleKey = "requiresBackgroundBundle" + private static let metadataBackgroundProtocolVersionKey = "backgroundProtocolVersion" + private static let supportedBackgroundProtocolVersion = "1" public static func documentDirectory() -> String { NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)[0] @@ -147,8 +164,8 @@ public class BundleUpdateStore: NSObject { } public static func getWebEmbedPath() -> String { - guard let dir = currentBundleDir() else { return "" } - return (dir as NSString).appendingPathComponent("web-embed") + guard let bundleInfo = validatedCurrentBundleInfo() else { return "" } + return (bundleInfo.bundleDirPath as NSString).appendingPathComponent("web-embed") } public static func calculateSHA256(_ filePath: String) -> String? { @@ -310,13 +327,65 @@ public class BundleUpdateStore: NSObject { return metadataPath } - public static func getMetadataFileContent(_ currentBundleVersion: String) -> [String: String]? { + public static func getMetadataFileContent(_ currentBundleVersion: String) -> [String: Any]? { guard let path = getMetadataFilePath(currentBundleVersion), let data = FileManager.default.contents(atPath: path), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { return nil } + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } return json } + private static func isReservedMetadataKey(_ key: String) -> Bool { + key == metadataRequiresBackgroundBundleKey || key == metadataBackgroundProtocolVersionKey + } + + private static func metadataStringValue( + _ metadata: [String: Any], + key: String, + ) -> String? { + if let value = metadata[key] as? String { + return value + } + if let value = metadata[key] as? NSNumber { + return value.stringValue + } + if let value = metadata[key] as? Bool { + return value ? "true" : "false" + } + return nil + } + + private static func metadataBoolValue( + _ metadata: [String: Any], + key: String, + ) -> Bool { + if let value = metadata[key] as? Bool { + return value + } + if let value = metadata[key] as? NSNumber { + return value.boolValue + } + if let value = metadata[key] as? String { + return ["1", "true", "yes"].contains(value.lowercased()) + } + return false + } + + private static func fileMetadataEntries( + from metadata: [String: Any], + ) -> [String: String] { + var entries: [String: String] = [:] + for (key, value) in metadata { + if isReservedMetadataKey(key) { + continue + } + guard let hash = value as? String else { + continue + } + entries[key] = hash + } + return entries + } + /// Returns true if OneKey developer mode (DevSettings) is enabled. /// Reads the persisted value from MMKV storage written by the JS ServiceDevSetting layer. public static func isDevSettingsEnabled() -> Bool { @@ -466,11 +535,12 @@ public class BundleUpdateStore: NSObject { return true } - public static func validateAllFilesInDir(_ dirPath: String, metadata: [String: String], appVersion: String, bundleVersion: String) -> Bool { + public static func validateAllFilesInDir(_ dirPath: String, metadata: [String: Any], appVersion: String, bundleVersion: String) -> Bool { let parentBundleDir = bundleDir() let folderName = "\(appVersion)-\(bundleVersion)" let jsBundleDir = (parentBundleDir as NSString).appendingPathComponent(folderName) + "/" let fm = FileManager.default + let fileEntries = fileMetadataEntries(from: metadata) guard let enumerator = fm.enumerator(atPath: dirPath) else { return false } while let file = enumerator.nextObject() as? String { @@ -480,7 +550,7 @@ public class BundleUpdateStore: NSObject { if fm.fileExists(atPath: fullPath, isDirectory: &isDir), isDir.boolValue { continue } let relativePath = fullPath.replacingOccurrences(of: jsBundleDir, with: "") - guard let expectedSHA256 = metadata[relativePath] else { + guard let expectedSHA256 = fileEntries[relativePath] else { OneKeyLog.error("BundleUpdate", "[bundle-verify] File on disk not found in metadata: \(relativePath)") return false } @@ -495,7 +565,7 @@ public class BundleUpdateStore: NSObject { } // Verify completeness - for key in metadata.keys { + for key in fileEntries.keys { let expectedFilePath = jsBundleDir + key if !fm.fileExists(atPath: expectedFilePath) { OneKeyLog.error("BundleUpdate", "[bundle-verify] File listed in metadata but missing on disk: \(key)") @@ -505,7 +575,58 @@ public class BundleUpdateStore: NSObject { return true } - public static func currentBundleMainJSBundle() -> String? { + static func validateBundlePairCompatibility( + bundleDirPath: String, + metadata: [String: Any], + ) -> Bool { + let mainBundlePath = (bundleDirPath as NSString) + .appendingPathComponent(mainBundleEntryFileName) + guard FileManager.default.fileExists(atPath: mainBundlePath) else { + OneKeyLog.error( + "BundleUpdate", + "bundle pair invalid: main.jsbundle.hbc is missing at \(mainBundlePath)", + ) + return false + } + + let requiresBackgroundBundle = metadataBoolValue( + metadata, + key: metadataRequiresBackgroundBundleKey, + ) + if !requiresBackgroundBundle { + return true + } + + let protocolVersion = metadataStringValue( + metadata, + key: metadataBackgroundProtocolVersionKey, + ) ?? "" + if protocolVersion.isEmpty || protocolVersion != supportedBackgroundProtocolVersion { + OneKeyLog.error( + "BundleUpdate", + "backgroundProtocolVersion mismatch: expected=\(supportedBackgroundProtocolVersion), actual=\(protocolVersion)", + ) + return false + } + + let backgroundBundlePath = (bundleDirPath as NSString) + .appendingPathComponent(backgroundBundleEntryFileName) + guard FileManager.default.fileExists(atPath: backgroundBundlePath) else { + OneKeyLog.error( + "BundleUpdate", + "requiresBackgroundBundle is true but background.bundle is missing at \(backgroundBundlePath)", + ) + return false + } + + return true + } + + private static func validatedCurrentBundleInfo() -> ( + bundleDirPath: String, + currentBundleVersion: String, + metadata: [String: Any] + )? { processPreLaunchPendingTask() guard let currentBundleVer = currentBundleVersion() else { OneKeyLog.warn("BundleUpdate", "getJsBundlePath: no currentBundleVersion stored") @@ -582,12 +703,32 @@ public class BundleUpdateStore: NSObject { } } - let mainJSBundle = (folderName as NSString).appendingPathComponent("main.jsbundle.hbc") - guard FileManager.default.fileExists(atPath: mainJSBundle) else { - OneKeyLog.info("BundleUpdate", "mainJSBundleFile does not exist") + if !validateBundlePairCompatibility(bundleDirPath: folderName, metadata: metadata) { return nil } - return mainJSBundle + + return (folderName, currentBundleVer, metadata) + } + + private static func currentBundleEntryPath(_ entryFileName: String) -> String? { + guard let bundleInfo = validatedCurrentBundleInfo() else { + return nil + } + + let entryPath = (bundleInfo.bundleDirPath as NSString).appendingPathComponent(entryFileName) + guard FileManager.default.fileExists(atPath: entryPath) else { + OneKeyLog.info("BundleUpdate", "\(entryFileName) does not exist") + return nil + } + return entryPath + } + + public static func currentBundleMainJSBundle() -> String? { + currentBundleEntryPath(mainBundleEntryFileName) + } + + public static func currentBundleBackgroundJSBundle() -> String? { + currentBundleEntryPath(backgroundBundleEntryFileName) } // Fallback data management @@ -1050,6 +1191,11 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { try? FileManager.default.removeItem(atPath: destination) throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Extracted files verification against metadata failed"]) } + if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: destination, metadata: metadata) { + OneKeyLog.error("BundleUpdate", "verifyBundleASC: bundle pair compatibility check failed") + try? FileManager.default.removeItem(atPath: destination) + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle pair compatibility check failed"]) + } OneKeyLog.info("BundleUpdate", "verifyBundleASC: all verifications passed, appVersion=\(appVersion), bundleVersion=\(bundleVersion)") } @@ -1314,6 +1460,20 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { } } + func getBackgroundJsBundlePath() throws -> String { + let path = BundleUpdateStore.currentBundleBackgroundJSBundle() ?? "" + OneKeyLog.debug("BundleUpdate", "getBackgroundJsBundlePath: \(path.isEmpty ? "(empty/no bundle)" : path)") + return path + } + + func getBackgroundJsBundlePathAsync() throws -> Promise { + return Promise.async { + let path = BundleUpdateStore.currentBundleBackgroundJSBundle() ?? "" + OneKeyLog.info("BundleUpdate", "getBackgroundJsBundlePathAsync: \(path.isEmpty ? "(empty/no bundle)" : path)") + return path + } + } + func getNativeAppVersion() throws -> Promise { return Promise.async { let version = BundleUpdateStore.getCurrentNativeVersion() @@ -1422,7 +1582,7 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "metadata.json not found"]) } guard let data = FileManager.default.contents(atPath: metadataJsonPath), - let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: String] else { + let metadata = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: failed to parse metadata.json") throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse metadata.json"]) } @@ -1431,6 +1591,10 @@ class ReactNativeBundleUpdate: HybridReactNativeBundleUpdateSpec { OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: file integrity check failed") throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "File integrity check failed"]) } + if !BundleUpdateStore.validateBundlePairCompatibility(bundleDirPath: bundlePath, metadata: metadata) { + OneKeyLog.error("BundleUpdate", "verifyExtractedBundle: bundle pair compatibility check failed") + throw NSError(domain: "BundleUpdate", code: -1, userInfo: [NSLocalizedDescriptionKey: "Bundle pair compatibility check failed"]) + } OneKeyLog.info("BundleUpdate", "verifyExtractedBundle: all files verified OK, fileCount=\(metadata.count)") } } diff --git a/native-modules/react-native-bundle-update/package.json b/native-modules/react-native-bundle-update/package.json index 0575c24e..cba255c9 100644 --- a/native-modules/react-native-bundle-update/package.json +++ b/native-modules/react-native-bundle-update/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-bundle-update", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-bundle-update", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts b/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts index 71fb379d..5a993236 100644 --- a/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts +++ b/native-modules/react-native-bundle-update/src/ReactNativeBundleUpdate.nitro.ts @@ -109,6 +109,8 @@ export interface ReactNativeBundleUpdate getWebEmbedPathAsync(): Promise; getJsBundlePath(): string; getJsBundlePathAsync(): Promise; + getBackgroundJsBundlePath(): string; + getBackgroundJsBundlePathAsync(): Promise; getNativeAppVersion(): Promise; getNativeBuildNumber(): Promise; getBuiltinBundleVersion(): Promise; diff --git a/native-modules/react-native-check-biometric-auth-changed/package.json b/native-modules/react-native-check-biometric-auth-changed/package.json index c14a3aac..ba1db1ac 100644 --- a/native-modules/react-native-check-biometric-auth-changed/package.json +++ b/native-modules/react-native-check-biometric-auth-changed/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-check-biometric-auth-changed", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-check-biometric-auth-changed", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-cloud-fs/CloudFs.podspec b/native-modules/react-native-cloud-fs/CloudFs.podspec new file mode 100644 index 00000000..be9e77c4 --- /dev/null +++ b/native-modules/react-native-cloud-fs/CloudFs.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "CloudFs" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-cloud-fs.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-cloud-fs/README.md b/native-modules/react-native-cloud-fs/README.md new file mode 100644 index 00000000..3a47966c --- /dev/null +++ b/native-modules/react-native-cloud-fs/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-cloud-fs + +First, a sincere thank-you to the +`react-native-cloud-fs` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[nicola/react-native-cloud-fs](https://github.com/nicola/react-native-cloud-fs). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-cloud-fs`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-cloud-fs` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [nicola/react-native-cloud-fs](https://github.com/nicola/react-native-cloud-fs) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to everyone who contributes to +`react-native-cloud-fs` 💙 diff --git a/native-modules/react-native-cloud-fs/android/build.gradle b/native-modules/react-native-cloud-fs/android/build.gradle new file mode 100644 index 00000000..4e515d26 --- /dev/null +++ b/native-modules/react-native-cloud-fs/android/build.gradle @@ -0,0 +1,87 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNCloudFs_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNCloudFs_" + name]).toInteger() +} + +android { + namespace "com.rncloudfs" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + // Google Drive / Sign-In + implementation "com.google.android.gms:play-services-auth:21.3.0" + implementation "com.google.http-client:google-http-client-gson:1.44.1" + implementation("com.google.api-client:google-api-client-android:2.7.0") { + exclude group: "org.apache.httpcomponents" + } + implementation("com.google.apis:google-api-services-drive:v3-rev20241027-2.0.0") { + exclude group: "org.apache.httpcomponents" + } +} diff --git a/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/DriveServiceHelper.kt b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/DriveServiceHelper.kt new file mode 100644 index 00000000..f66fcdd2 --- /dev/null +++ b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/DriveServiceHelper.kt @@ -0,0 +1,108 @@ +package com.rncloudfs + +import android.util.Log +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks +import com.google.api.client.http.FileContent +import com.google.api.services.drive.Drive +import com.google.api.services.drive.model.FileList +import java.io.BufferedReader +import java.io.InputStreamReader +import java.util.concurrent.Executors + +class DriveServiceHelper(private val driveService: Drive) { + + private val executor = Executors.newSingleThreadExecutor() + + fun saveFile( + sourcePath: String, + destinationPath: String, + mimeType: String?, + useDocumentsFolder: Boolean + ): Task { + var existingFileId: String? = null + val fileList = Tasks.await(queryFiles(useDocumentsFolder)) + for (file in fileList.files) { + if (file.name.equals(destinationPath, ignoreCase = true)) { + existingFileId = file.id + } + } + return createFile(sourcePath, destinationPath, mimeType, useDocumentsFolder, existingFileId) + } + + fun createFile( + sourcePath: String, + destinationPath: String, + mimeType: String?, + useDocumentsFolder: Boolean, + fileId: String? + ): Task { + return Tasks.call(executor) { + try { + val sourceFile = java.io.File(sourcePath) + val mediaContent = FileContent(mimeType, sourceFile) + val parentFolder = listOf(if (useDocumentsFolder) "root" else "appDataFolder") + val metadata = com.google.api.services.drive.model.File() + .setMimeType(mimeType) + .setName(destinationPath) + if (fileId == null) { + metadata.parents = parentFolder + } + + val googleFile = if (fileId != null) { + driveService.files().update(fileId, metadata, mediaContent).execute() + } else { + driveService.files().create(metadata, mediaContent).execute() + } + + googleFile?.id ?: throw java.io.IOException("Null result when requesting file creation.") + } catch (e: Exception) { + Log.e(TAG, e.toString()) + throw e + } + } + } + + fun checkIfFileExists(fileId: String): Task { + return Tasks.call(executor) { + val metadata = driveService.files().get(fileId).execute() + metadata != null + } + } + + fun deleteFile(fileId: String): Task { + return Tasks.call(executor) { + driveService.files().delete(fileId).execute() + true + } + } + + fun readFile(fileId: String): Task { + return Tasks.call(executor) { + driveService.files().get(fileId).executeMediaAsInputStream().use { inputStream -> + BufferedReader(InputStreamReader(inputStream)).use { reader -> + val sb = StringBuilder() + var line: String? + while (reader.readLine().also { line = it } != null) { + sb.append(line) + } + sb.toString() + } + } + } + } + + fun queryFiles(useDocumentsFolder: Boolean): Task { + return Tasks.call(executor) { + driveService.files().list() + .setSpaces(if (useDocumentsFolder) "drive" else "appDataFolder") + .setFields("nextPageToken, files(id, name, modifiedTime)") + .setPageSize(100) + .execute() + } + } + + companion object { + private const val TAG = "DriveServiceHelper" + } +} diff --git a/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsModule.kt b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsModule.kt new file mode 100644 index 00000000..6ecea155 --- /dev/null +++ b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsModule.kt @@ -0,0 +1,425 @@ +package com.rncloudfs + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.webkit.MimeTypeMap +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.LifecycleEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableNativeArray +import com.facebook.react.bridge.WritableNativeMap +import com.facebook.react.module.annotations.ReactModule +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.google.api.client.http.javanet.NetHttpTransport +import com.google.api.client.googleapis.extensions.android.gms.auth.GoogleAccountCredential +import com.google.api.client.googleapis.extensions.android.gms.auth.UserRecoverableAuthIOException +import com.google.api.client.json.gson.GsonFactory +import com.google.api.services.drive.Drive +import com.google.api.services.drive.DriveScopes +import java.util.Collections + +@ReactModule(name = RNCloudFsModule.NAME) +class RNCloudFsModule(private val reactContext: ReactApplicationContext) : + NativeCloudFsSpec(reactContext), LifecycleEventListener, ActivityEventListener { + + private var mDriveServiceHelper: DriveServiceHelper? = null + private var signInPromise: Promise? = null + private var mPendingPromise: Promise? = null + private var mPendingOptions: ReadableMap? = null + private var mPendingOperation: String? = null + + init { + reactContext.addLifecycleEventListener(this) + reactContext.addActivityEventListener(this) + } + + companion object { + const val NAME = "RNCloudFs" + private const val TAG = "RNCloudFs" + private const val REQUEST_CODE_SIGN_IN = 1 + private const val REQUEST_AUTHORIZATION = 11 + private const val COPY_TO_CLOUD = "CopyToCloud" + private const val LIST_FILES = "ListFiles" + } + + override fun getName(): String = NAME + + // region iOS-only stubs + + override fun isAvailable(promise: Promise) { + promise.resolve(false) + } + + override fun createFile(options: ReadableMap, promise: Promise) { + promise.reject("NOT_AVAILABLE", "iCloud is not available on Android") + } + + override fun getIcloudDocument(filename: String, promise: Promise) { + promise.reject("NOT_AVAILABLE", "iCloud is not available on Android") + } + + override fun syncCloud(promise: Promise) { + promise.reject("NOT_AVAILABLE", "iCloud is not available on Android") + } + + // endregion + + // region Google Sign-In + + override fun loginIfNeeded(promise: Promise) { + if (mDriveServiceHelper == null) { + val account = GoogleSignIn.getLastSignedInAccount(reactContext) + if (account == null) { + signInPromise = promise + requestSignIn() + } else { + val credential = GoogleAccountCredential.usingOAuth2( + reactContext, Collections.singleton(DriveScopes.DRIVE_APPDATA) + ) + credential.selectedAccount = account.account + val googleDriveService = Drive.Builder( + NetHttpTransport(), + GsonFactory(), + credential + ).build() + mDriveServiceHelper = DriveServiceHelper(googleDriveService) + promise.resolve(true) + } + } else { + promise.resolve(true) + } + } + + override fun logout(promise: Promise) { + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE_FILE)) + .build() + val client: GoogleSignInClient = GoogleSignIn.getClient(reactContext, signInOptions) + mDriveServiceHelper = null + client.signOut() + .addOnSuccessListener { promise.resolve(true) } + .addOnFailureListener { exception -> + Log.e(TAG, "Couldn't log out.", exception) + promise.reject(exception) + } + } + + override fun getCurrentlySignedInUserData(promise: Promise) { + val account = GoogleSignIn.getLastSignedInAccount(reactContext) + if (account == null) { + promise.resolve(null) + } else { + val photoUrl: Uri? = account.photoUrl + val resultData = WritableNativeMap() + resultData.putString("email", account.email) + resultData.putString("name", account.displayName) + resultData.putString("avatarUrl", photoUrl?.toString()) + promise.resolve(resultData) + } + } + + private fun requestSignIn() { + Log.d(TAG, "Requesting sign-in") + val signInOptions = GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestEmail() + .requestScopes(Scope(DriveScopes.DRIVE_FILE)) + .build() + val client: GoogleSignInClient = GoogleSignIn.getClient(reactContext, signInOptions) + reactContext.startActivityForResult(client.signInIntent, REQUEST_CODE_SIGN_IN, null) + } + + // endregion + + // region Google Drive operations + + override fun fileExists(options: ReadableMap, promise: Promise) { + val helper = mDriveServiceHelper + if (helper != null) { + val fileId = options.getString("fileId") ?: "" + Log.d(TAG, "Checking file $fileId") + helper.checkIfFileExists(fileId) + .addOnSuccessListener { exists -> promise.resolve(exists) } + .addOnFailureListener { exception -> + try { + val e = exception as UserRecoverableAuthIOException + reactContext.startActivityForResult(e.intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + Log.e(TAG, "Couldn't check file.", exception) + promise.reject(exception) + } + } + } else { + promise.reject("NOT_LOGGED_IN", "Google Drive not initialized. Call loginIfNeeded first.") + } + } + + override fun deleteFromCloud(item: ReadableMap, promise: Promise) { + val helper = mDriveServiceHelper + if (helper != null) { + val fileId = item.getString("id") ?: "" + Log.d(TAG, "Deleting file $fileId") + helper.deleteFile(fileId) + .addOnSuccessListener { deleted -> promise.resolve(deleted) } + .addOnFailureListener { exception -> + try { + val e = exception as UserRecoverableAuthIOException + reactContext.startActivityForResult(e.intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + Log.e(TAG, "Couldn't delete file.", exception) + promise.reject(exception) + } + } + } else { + promise.reject("NOT_LOGGED_IN", "Google Drive not initialized. Call loginIfNeeded first.") + } + } + + override fun listFiles(options: ReadableMap, promise: Promise) { + val helper = mDriveServiceHelper + if (helper != null) { + Log.d(TAG, "Querying for files.") + val useDocumentsFolder = if (options.hasKey("scope")) { + options.getString("scope")?.lowercase() == "visible" + } else { + true + } + try { + helper.queryFiles(useDocumentsFolder) + .addOnSuccessListener { fileList -> + val files = WritableNativeArray() + for (file in fileList.files) { + val fileInfo = WritableNativeMap() + fileInfo.putString("name", file.name) + fileInfo.putString("id", file.id) + fileInfo.putString("lastModified", file.modifiedTime.toString()) + files.pushMap(fileInfo) + } + val result = WritableNativeMap() + result.putArray("files", files) + promise.resolve(result) + clearPendingOperations() + } + .addOnFailureListener { exception -> + clearPendingOperations() + try { + Log.e(TAG, "Unable to query files: ${exception.cause?.message}") + val e = exception as UserRecoverableAuthIOException + mPendingPromise = promise + mPendingOptions = options + mPendingOperation = LIST_FILES + reactContext.startActivityForResult(e.intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + promise.reject(e) + } + } + } catch (exception: Exception) { + try { + val e = exception as java.util.concurrent.ExecutionException + mPendingPromise = promise + mPendingOptions = options + mPendingOperation = LIST_FILES + val intent = (e.cause as UserRecoverableAuthIOException).intent + reactContext.startActivityForResult(intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + promise.reject(exception) + Log.e(TAG, "Unable to query files: ${exception.cause?.message}") + } + } + } else { + promise.reject("NOT_LOGGED_IN", "Google Drive not initialized. Call loginIfNeeded first.") + } + } + + override fun copyToCloud(options: ReadableMap, promise: Promise) { + val helper = mDriveServiceHelper + if (helper != null) { + if (!options.hasKey("sourcePath")) { + promise.reject("error", "sourcePath not specified") + return + } + val source = options.getMap("sourcePath") + var uriOrPath = source?.getString("uri") + if (uriOrPath == null) { + uriOrPath = source?.getString("path") + } + if (uriOrPath == null) { + promise.reject("no path", "no source uri or path was specified") + return + } + if (!options.hasKey("targetPath")) { + promise.reject("error", "targetPath not specified") + return + } + val destinationPath = options.getString("targetPath") ?: "" + val mimeType = if (options.hasKey("mimetype")) options.getString("mimetype") else null + val useDocumentsFolder = if (options.hasKey("scope")) { + options.getString("scope")?.lowercase() == "visible" + } else { + true + } + val actualMimeType = if (mimeType == null) guessMimeType(uriOrPath) else null + + try { + helper.saveFile(uriOrPath, destinationPath, actualMimeType, useDocumentsFolder) + .addOnSuccessListener { fileId -> + Log.d(TAG, "Saving $fileId") + promise.resolve(fileId) + clearPendingOperations() + } + .addOnFailureListener { exception -> + clearPendingOperations() + try { + val e = exception as UserRecoverableAuthIOException + reactContext.startActivityForResult(e.intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + Log.e(TAG, "Couldn't create file.", exception) + } + promise.reject(exception) + } + } catch (exception: Exception) { + try { + val e = exception as java.util.concurrent.ExecutionException + mPendingPromise = promise + mPendingOptions = options + mPendingOperation = COPY_TO_CLOUD + val intent = (e.cause as UserRecoverableAuthIOException).intent + reactContext.startActivityForResult(intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + promise.reject(exception) + Log.e(TAG, "Couldn't create file.", exception) + } + } + } else { + promise.reject("NOT_LOGGED_IN", "Google Drive not initialized. Call loginIfNeeded first.") + } + } + + override fun getGoogleDriveDocument(fileId: String, promise: Promise) { + val helper = mDriveServiceHelper + if (helper != null) { + Log.d(TAG, "Reading file $fileId") + helper.readFile(fileId) + .addOnSuccessListener { content -> promise.resolve(content) } + .addOnFailureListener { exception -> + try { + val e = exception as UserRecoverableAuthIOException + reactContext.startActivityForResult(e.intent, REQUEST_AUTHORIZATION, null) + } catch (e: Exception) { + Log.e(TAG, "Couldn't read file.", exception) + promise.reject(exception) + } + } + } else { + promise.reject("NOT_LOGGED_IN", "Google Drive not initialized. Call loginIfNeeded first.") + } + } + + // endregion + + // region Activity result handling + + private fun clearPendingOperations() { + mPendingOperation = null + mPendingPromise = null + mPendingOptions = null + } + + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + REQUEST_CODE_SIGN_IN -> { + val task = GoogleSignIn.getSignedInAccountFromIntent(data) + handleSignInResult(task) + } + REQUEST_AUTHORIZATION -> { + if (resultCode == Activity.RESULT_OK && data != null) { + val copiedPendingOperation = mPendingOperation + if (copiedPendingOperation != null) { + reactContext.runOnNativeModulesQueueThread { + mPendingOperation = null + when (copiedPendingOperation) { + COPY_TO_CLOUD -> { + try { + mPendingOptions?.let { copyToCloud(it, mPendingPromise!!) } + } catch (e: Exception) { + mPendingPromise?.reject(e) + clearPendingOperations() + } + } + LIST_FILES -> { + try { + mPendingOptions?.let { listFiles(it, mPendingPromise!!) } + } catch (e: Exception) { + mPendingPromise?.reject(e) + clearPendingOperations() + } + } + } + } + } + } else if (resultCode == Activity.RESULT_CANCELED && mPendingPromise != null) { + mPendingPromise?.reject("canceled", "User canceled") + } else if (mPendingPromise != null) { + mPendingPromise?.reject( + "unknown error", + "Operation failed: $mPendingOperation result code $resultCode" + ) + } + } + } + } + + private fun handleSignInResult(completedTask: com.google.android.gms.tasks.Task) { + try { + val googleAccount = completedTask.getResult(ApiException::class.java) + Log.d(TAG, "Signed in as ${googleAccount.email}") + + val credential = GoogleAccountCredential.usingOAuth2( + reactContext, Collections.singleton(DriveScopes.DRIVE_APPDATA) + ) + credential.selectedAccount = googleAccount.account + val googleDriveService = Drive.Builder( + NetHttpTransport(), + GsonFactory(), + credential + ).build() + + mDriveServiceHelper = DriveServiceHelper(googleDriveService) + + signInPromise?.resolve(true) + signInPromise = null + } catch (e: ApiException) { + Log.w(TAG, "signInResult:failed code=${e.statusCode}") + signInPromise?.reject("signInResult:${e.statusCode}", e.message) + signInPromise = null + } + } + + // endregion + + // region Lifecycle + + override fun onHostResume() {} + override fun onHostPause() {} + override fun onHostDestroy() {} + override fun onNewIntent(intent: Intent) {} + + // endregion + + private fun guessMimeType(url: String): String? { + val extension = MimeTypeMap.getFileExtensionFromUrl(url) + return if (extension != null) { + MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + } else { + null + } + } +} diff --git a/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsPackage.kt b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsPackage.kt new file mode 100644 index 00000000..7b5fe179 --- /dev/null +++ b/native-modules/react-native-cloud-fs/android/src/main/java/com/rncloudfs/RNCloudFsPackage.kt @@ -0,0 +1,33 @@ +package com.rncloudfs + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class RNCloudFsPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == RNCloudFsModule.NAME) { + RNCloudFsModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[RNCloudFsModule.NAME] = ReactModuleInfo( + RNCloudFsModule.NAME, + RNCloudFsModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-cloud-fs/babel.config.js b/native-modules/react-native-cloud-fs/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-cloud-fs/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-cloud-fs/ios/CloudFs.h b/native-modules/react-native-cloud-fs/ios/CloudFs.h new file mode 100644 index 00000000..159149cb --- /dev/null +++ b/native-modules/react-native-cloud-fs/ios/CloudFs.h @@ -0,0 +1,7 @@ +#import + +@interface CloudFs : NativeCloudFsSpecBase + +@property (nonatomic, strong) NSMetadataQuery *query; + +@end diff --git a/native-modules/react-native-cloud-fs/ios/CloudFs.mm b/native-modules/react-native-cloud-fs/ios/CloudFs.mm new file mode 100644 index 00000000..ab8c7630 --- /dev/null +++ b/native-modules/react-native-cloud-fs/ios/CloudFs.mm @@ -0,0 +1,424 @@ +#import "CloudFs.h" +#import + +@implementation CloudFs + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNCloudFs"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_queue_create("com.onekey.CloudFs.queue", DISPATCH_QUEUE_SERIAL); +} + +// MARK: - isAvailable + +- (void)isAvailable:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSURL *ubiquityURL = [self icloudDirectory]; + if (ubiquityURL != nil) { + return resolve(@YES); + } + return resolve(@NO); +} + +// MARK: - createFile + +- (void)createFile:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *destinationPath = [options objectForKey:@"targetPath"]; + NSString *content = [options objectForKey:@"content"]; + NSString *scope = [options objectForKey:@"scope"]; + bool documentsFolder = !scope || [scope caseInsensitiveCompare:@"visible"] == NSOrderedSame; + + NSString *tempFile = [NSTemporaryDirectory() stringByAppendingPathComponent:[[NSUUID UUID] UUIDString]]; + + NSError *error; + [content writeToFile:tempFile atomically:YES encoding:NSUTF8StringEncoding error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + + [self moveToICloudDirectory:documentsFolder tempFile:tempFile destinationPath:destinationPath resolve:resolve reject:reject]; +} + +// MARK: - fileExists + +- (void)fileExists:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *destinationPath = [options objectForKey:@"targetPath"]; + NSString *scope = [options objectForKey:@"scope"]; + bool documentsFolder = !scope || [scope caseInsensitiveCompare:@"visible"] == NSOrderedSame; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSURL *ubiquityURL = documentsFolder ? [self icloudDocumentsDirectory] : [self icloudDirectory]; + + if (ubiquityURL) { + NSURL *dir = [ubiquityURL URLByAppendingPathComponent:destinationPath]; + NSString *dirPath = [dir.path stringByStandardizingPath]; + bool exists = [fileManager fileExistsAtPath:dirPath]; + return resolve(@(exists)); + } else { + return reject(@"error", [NSString stringWithFormat:@"could not access iCloud drive '%@'", destinationPath], nil); + } +} + +// MARK: - listFiles + +- (void)listFiles:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *destinationPath = [options objectForKey:@"targetPath"]; + NSString *scope = [options objectForKey:@"scope"]; + bool documentsFolder = !scope || [scope caseInsensitiveCompare:@"visible"] == NSOrderedSame; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSDateFormatter *dateFormatter = [[NSDateFormatter alloc] init]; + [dateFormatter setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZZZ"]; + + NSURL *ubiquityURL = documentsFolder ? [self icloudDocumentsDirectory] : [self icloudDirectory]; + + if (ubiquityURL) { + NSURL *target = [ubiquityURL URLByAppendingPathComponent:destinationPath]; + NSMutableArray *fileData = [NSMutableArray new]; + NSError *error = nil; + + BOOL isDirectory; + [fileManager fileExistsAtPath:[target path] isDirectory:&isDirectory]; + + NSURL *dirPath; + NSArray *contents; + if (isDirectory) { + contents = [fileManager contentsOfDirectoryAtPath:[target path] error:&error]; + dirPath = target; + } else { + contents = @[[target lastPathComponent]]; + dirPath = [target URLByDeletingLastPathComponent]; + } + + if (error) { + return reject(@"error", error.description, nil); + } + + [contents enumerateObjectsUsingBlock:^(id object, NSUInteger idx, BOOL *stop) { + NSURL *fileUrl = [dirPath URLByAppendingPathComponent:object]; + NSError *attrError; + NSDictionary *attributes = [fileManager attributesOfItemAtPath:[fileUrl path] error:&attrError]; + if (attrError) { + return; + } + + NSFileAttributeType type = [attributes objectForKey:NSFileType]; + bool isDir = type == NSFileTypeDirectory; + bool isFile = type == NSFileTypeRegular; + + if (!isDir && !isFile) return; + + NSDate *modDate = [attributes objectForKey:NSFileModificationDate]; + NSError *shareError; + NSURL *shareUrl = [fileManager URLForPublishingUbiquitousItemAtURL:fileUrl expirationDate:nil error:&shareError]; + + [fileData addObject:@{ + @"name": object, + @"path": [fileUrl path], + @"uri": shareUrl ? [shareUrl absoluteString] : [NSNull null], + @"size": [attributes objectForKey:NSFileSize], + @"lastModified": [dateFormatter stringFromDate:modDate], + @"isDirectory": @(isDir), + @"isFile": @(isFile) + }]; + }]; + + NSString *relativePath = [[dirPath path] stringByReplacingOccurrencesOfString:[ubiquityURL path] withString:@"."]; + + return resolve(@{ + @"files": fileData, + @"path": relativePath + }); + } else { + return reject(@"error", [NSString stringWithFormat:@"could not list iCloud drive '%@'", destinationPath], nil); + } +} + +// MARK: - getIcloudDocument + +- (void)getIcloudDocument:(NSString *)filename + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + __block bool resolved = NO; + _query = [[NSMetadataQuery alloc] init]; + [_query setSearchScopes:@[NSMetadataQueryUbiquitousDocumentsScope, NSMetadataQueryUbiquitousDataScope]]; + + NSPredicate *pred = [NSPredicate predicateWithFormat:@"%K == %@", NSMetadataItemFSNameKey, filename]; + [_query setPredicate:pred]; + + [[NSNotificationCenter defaultCenter] addObserverForName:NSMetadataQueryDidFinishGatheringNotification + object:_query + queue:[NSOperationQueue currentQueue] + usingBlock:^(NSNotification __strong *notification) { + NSMetadataQuery *query = [notification object]; + [query disableUpdates]; + [query stopQuery]; + for (NSMetadataItem *item in query.results) { + if ([[item valueForAttribute:NSMetadataItemFSNameKey] isEqualToString:filename]) { + resolved = YES; + NSURL *url = [item valueForAttribute:NSMetadataItemURLKey]; + bool fileIsReady = [self downloadFileIfNotAvailable:item]; + if (fileIsReady) { + NSData *data = [NSData dataWithContentsOfURL:url]; + NSString *content = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding]; + return resolve(content); + } else { + [self getIcloudDocument:filename resolve:resolve reject:reject]; + } + } + } + if (!resolved) { + return reject(@"error", [NSString stringWithFormat:@"item not found '%@'", filename], nil); + } + }]; + + dispatch_async(dispatch_get_main_queue(), ^{ + [self->_query startQuery]; + }); +} + +// MARK: - deleteFromCloud + +- (void)deleteFromCloud:(NSDictionary *)item + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSError *error; + NSFileManager *fileManager = [NSFileManager defaultManager]; + [fileManager removeItemAtPath:item[@"path"] error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + return resolve(@YES); +} + +// MARK: - copyToCloud + +- (void)copyToCloud:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSDictionary *source = [options objectForKey:@"sourcePath"]; + NSString *destinationPath = [options objectForKey:@"targetPath"]; + NSString *scope = [options objectForKey:@"scope"]; + bool documentsFolder = !scope || [scope caseInsensitiveCompare:@"visible"] == NSOrderedSame; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + + NSString *sourceUri = [source objectForKey:@"uri"]; + if (!sourceUri) { + sourceUri = [source objectForKey:@"path"]; + } + + if ([sourceUri hasPrefix:@"file:/"] || [sourceUri hasPrefix:@"/"]) { + NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"^file:/+" options:NSRegularExpressionCaseInsensitive error:nil]; + NSString *modifiedSourceUri = [regex stringByReplacingMatchesInString:sourceUri options:0 range:NSMakeRange(0, [sourceUri length]) withTemplate:@"/"]; + + if ([fileManager fileExistsAtPath:modifiedSourceUri isDirectory:nil]) { + NSURL *sourceURL = [NSURL fileURLWithPath:modifiedSourceUri]; + NSString *filename = [sourceUri lastPathComponent]; + NSString *tempFile = [NSTemporaryDirectory() stringByAppendingPathComponent:filename]; + + NSError *error; + if ([fileManager fileExistsAtPath:tempFile]) { + [fileManager removeItemAtPath:tempFile error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + } + + [fileManager copyItemAtPath:[sourceURL path] toPath:tempFile error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + + [self moveToICloudDirectory:documentsFolder tempFile:tempFile destinationPath:destinationPath resolve:resolve reject:reject]; + } else { + return reject(@"error", [NSString stringWithFormat:@"no such file or directory, open '%@'", sourceUri], nil); + } + } else { + NSURL *url = [NSURL URLWithString:sourceUri]; + NSData *urlData = [NSData dataWithContentsOfURL:url]; + if (urlData) { + NSString *filename = [sourceUri lastPathComponent]; + NSString *tempFile = [NSTemporaryDirectory() stringByAppendingPathComponent:filename]; + [urlData writeToFile:tempFile atomically:YES]; + [self moveToICloudDirectory:documentsFolder tempFile:tempFile destinationPath:destinationPath resolve:resolve reject:reject]; + } else { + return reject(@"error", [NSString stringWithFormat:@"cannot download '%@'", sourceUri], nil); + } + } +} + +// MARK: - syncCloud + +- (void)syncCloud:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + _query = [[NSMetadataQuery alloc] init]; + [_query setSearchScopes:@[NSMetadataQueryUbiquitousDocumentsScope, NSMetadataQueryUbiquitousDataScope]]; + [_query setPredicate:[NSPredicate predicateWithFormat:@"%K LIKE '*'", NSMetadataItemFSNameKey]]; + + dispatch_async(dispatch_get_main_queue(), ^{ + BOOL startedQuery = [self->_query startQuery]; + if (!startedQuery) { + reject(@"error", @"Failed to start query.\n", nil); + } + }); + + [[NSNotificationCenter defaultCenter] addObserverForName:NSMetadataQueryDidFinishGatheringNotification + object:_query + queue:[NSOperationQueue currentQueue] + usingBlock:^(NSNotification __strong *notification) { + NSMetadataQuery *query = [notification object]; + [query disableUpdates]; + [query stopQuery]; + for (NSMetadataItem *item in query.results) { + [self downloadFileIfNotAvailable:item]; + } + return resolve(@YES); + }]; +} + +// MARK: - Android-only stubs + +- (void)loginIfNeeded:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + resolve(@NO); +} + +- (void)logout:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + resolve(@NO); +} + +- (void)getGoogleDriveDocument:(NSString *)fileId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + reject(@"NOT_AVAILABLE", @"Google Drive is not available on iOS", nil); +} + +- (void)getCurrentlySignedInUserData:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + resolve([NSNull null]); +} + +// MARK: - Private helpers + +- (void)moveToICloudDirectory:(bool)documentsFolder + tempFile:(NSString *)tempFile + destinationPath:(NSString *)destinationPath + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSURL *ubiquityURL = documentsFolder ? [self icloudDocumentsDirectory] : [self icloudDirectory]; + [self moveToICloud:ubiquityURL tempFile:tempFile destinationPath:destinationPath resolve:resolve reject:reject]; +} + +- (void)moveToICloud:(NSURL *)ubiquityURL + tempFile:(NSString *)tempFile + destinationPath:(NSString *)destinationPath + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSString *destPath = destinationPath; + while ([destPath hasPrefix:@"/"]) { + destPath = [destPath substringFromIndex:1]; + } + + NSFileManager *fileManager = [NSFileManager defaultManager]; + + if (ubiquityURL) { + NSURL *targetFile = [ubiquityURL URLByAppendingPathComponent:destPath]; + NSURL *dir = [targetFile URLByDeletingLastPathComponent]; + NSURL *uniqueFile = targetFile; + + if ([fileManager fileExistsAtPath:uniqueFile.path]) { + NSError *error; + [fileManager removeItemAtPath:uniqueFile.path error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + } + + if (![fileManager fileExistsAtPath:dir.path]) { + [fileManager createDirectoryAtURL:dir withIntermediateDirectories:YES attributes:nil error:nil]; + } + + NSError *error; + [fileManager setUbiquitous:YES itemAtURL:[NSURL fileURLWithPath:tempFile] destinationURL:uniqueFile error:&error]; + if (error) { + return reject(@"error", error.description, nil); + } + + [fileManager removeItemAtPath:tempFile error:&error]; + return resolve(uniqueFile.path); + } else { + NSError *error; + [fileManager removeItemAtPath:tempFile error:&error]; + return reject(@"error", [NSString stringWithFormat:@"could not copy '%@' to iCloud drive", tempFile], nil); + } +} + +- (NSURL *)icloudDocumentsDirectory +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSURL *rootDirectory = [[self icloudDirectory] URLByAppendingPathComponent:@"Documents"]; + if (rootDirectory) { + if (![fileManager fileExistsAtPath:rootDirectory.path isDirectory:nil]) { + [fileManager createDirectoryAtURL:rootDirectory withIntermediateDirectories:YES attributes:nil error:nil]; + } + } + return rootDirectory; +} + +- (NSURL *)icloudDirectory +{ + NSFileManager *fileManager = [NSFileManager defaultManager]; + NSURL *rootDirectory = [fileManager URLForUbiquityContainerIdentifier:nil]; + return rootDirectory; +} + +- (BOOL)downloadFileIfNotAvailable:(NSMetadataItem *)item +{ + if ([[item valueForAttribute:NSMetadataUbiquitousItemDownloadingStatusKey] isEqualToString:NSMetadataUbiquitousItemDownloadingStatusCurrent]) { + return YES; + } + NSFileManager *fm = [NSFileManager defaultManager]; + NSError *downloadError = nil; + [fm startDownloadingUbiquitousItemAtURL:[item valueForAttribute:NSMetadataItemURLKey] error:&downloadError]; + [NSThread sleepForTimeInterval:0.3]; + return NO; +} + +@end diff --git a/native-modules/react-native-cloud-fs/package.json b/native-modules/react-native-cloud-fs/package.json new file mode 100644 index 00000000..2b70fe75 --- /dev/null +++ b/native-modules/react-native-cloud-fs/package.json @@ -0,0 +1,93 @@ +{ + "name": "@onekeyfe/react-native-cloud-fs", + "version": "3.0.18", + "description": "react-native-cloud-fs TurboModule for OneKey", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "prepare": "bob build", + "typecheck": "tsc", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-cloud-fs.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-cloud-fs/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-cloud-fs#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "devDependencies": { + "@react-native/babel-preset": "0.83.0", + "react": "19.2.0", + "react-native": "0.83.0", + "react-native-builder-bob": "^0.40.17", + "typescript": "^5.9.2" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNCloudFsSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rncloudfs" + }, + "ios": { + "modulesProvider": { + "RNCloudFs": "CloudFs" + } + } + } +} diff --git a/native-modules/react-native-cloud-fs/src/NativeCloudFs.ts b/native-modules/react-native-cloud-fs/src/NativeCloudFs.ts new file mode 100644 index 00000000..17c1b94a --- /dev/null +++ b/native-modules/react-native-cloud-fs/src/NativeCloudFs.ts @@ -0,0 +1,51 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + // Shared + isAvailable(): Promise; + syncCloud(): Promise; + listFiles(options: { + scope: string; + targetPath?: string; + }): Promise<{ + files: Array<{ + id: string; + name: string; + lastModified: string; + isFile?: boolean; + }>; + }>; + deleteFromCloud(item: { id: string; path?: string }): Promise; + fileExists(options: { + fileId?: string; + targetPath?: string; + scope?: string; + }): Promise; + copyToCloud(options: { + mimetype?: string | null; + scope: string; + sourcePath: { path?: string; uri?: string }; + targetPath: string; + }): Promise; + createFile(options: { + targetPath: string; + content: string; + scope?: string; + }): Promise; + + // iOS only + getIcloudDocument(filename: string): Promise; + + // Android only + loginIfNeeded(): Promise; + logout(): Promise; + getGoogleDriveDocument(fileId: string): Promise; + getCurrentlySignedInUserData(): Promise<{ + email: string; + name: string; + avatarUrl: string | null; + } | null>; +} + +export default TurboModuleRegistry.getEnforcing('RNCloudFs'); diff --git a/native-modules/react-native-cloud-fs/src/index.tsx b/native-modules/react-native-cloud-fs/src/index.tsx new file mode 100644 index 00000000..03f75c1c --- /dev/null +++ b/native-modules/react-native-cloud-fs/src/index.tsx @@ -0,0 +1,5 @@ +import NativeCloudFs from './NativeCloudFs'; + +export const CloudFs = NativeCloudFs; +export default NativeCloudFs; +export type { Spec as CloudFsSpec } from './NativeCloudFs'; diff --git a/native-modules/react-native-cloud-fs/tsconfig.build.json b/native-modules/react-native-cloud-fs/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-cloud-fs/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-cloud-fs/tsconfig.json b/native-modules/react-native-cloud-fs/tsconfig.json new file mode 100644 index 00000000..761ac268 --- /dev/null +++ b/native-modules/react-native-cloud-fs/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-cloud-fs": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-cloud-kit-module/package.json b/native-modules/react-native-cloud-kit-module/package.json index 7a9c749a..78df85a0 100644 --- a/native-modules/react-native-cloud-kit-module/package.json +++ b/native-modules/react-native-cloud-kit-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-cloud-kit-module", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-cloud-kit-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-device-utils/package.json b/native-modules/react-native-device-utils/package.json index 706c6266..fb7c78ec 100644 --- a/native-modules/react-native-device-utils/package.json +++ b/native-modules/react-native-device-utils/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-device-utils", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-device-utils", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-dns-lookup/DnsLookup.podspec b/native-modules/react-native-dns-lookup/DnsLookup.podspec new file mode 100644 index 00000000..e56e6855 --- /dev/null +++ b/native-modules/react-native-dns-lookup/DnsLookup.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "DnsLookup" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-dns-lookup.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-dns-lookup/README.md b/native-modules/react-native-dns-lookup/README.md new file mode 100644 index 00000000..4749a618 --- /dev/null +++ b/native-modules/react-native-dns-lookup/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-dns-lookup + +First, a sincere thank-you to the +`react-native-dns-lookup` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[nicola/react-native-dns-lookup](https://github.com/nicola/react-native-dns-lookup). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-dns-lookup`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-dns-lookup` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [nicola/react-native-dns-lookup](https://github.com/nicola/react-native-dns-lookup) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to everyone who contributes to +`react-native-dns-lookup` 💙 diff --git a/native-modules/react-native-dns-lookup/android/build.gradle b/native-modules/react-native-dns-lookup/android/build.gradle new file mode 100644 index 00000000..6c02ca8f --- /dev/null +++ b/native-modules/react-native-dns-lookup/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['DnsLookup_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["DnsLookup_" + name]).toInteger() +} + +android { + namespace "com.rnsdnslookup" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupModule.kt b/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupModule.kt new file mode 100644 index 00000000..fd2a9e08 --- /dev/null +++ b/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupModule.kt @@ -0,0 +1,33 @@ +package com.rnsdnslookup + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.WritableNativeArray +import com.facebook.react.module.annotations.ReactModule +import java.net.InetAddress + +@ReactModule(name = DnsLookupModule.NAME) +class DnsLookupModule(reactContext: ReactApplicationContext) : + NativeDnsLookupSpec(reactContext) { + + companion object { + const val NAME = "RNDnsLookup" + } + + override fun getName(): String = NAME + + override fun getIpAddresses(hostname: String, promise: Promise) { + Thread { + try { + val addresses = InetAddress.getAllByName(hostname) + val result = WritableNativeArray() + for (address in addresses) { + result.pushString(address.hostAddress) + } + promise.resolve(result) + } catch (e: Exception) { + promise.reject("DNS_LOOKUP_ERROR", e.message, e) + } + }.start() + } +} diff --git a/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupPackage.kt b/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupPackage.kt new file mode 100644 index 00000000..2570efc9 --- /dev/null +++ b/native-modules/react-native-dns-lookup/android/src/main/java/com/rnsdnslookup/DnsLookupPackage.kt @@ -0,0 +1,33 @@ +package com.rnsdnslookup + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class DnsLookupPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == DnsLookupModule.NAME) { + DnsLookupModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[DnsLookupModule.NAME] = ReactModuleInfo( + DnsLookupModule.NAME, + DnsLookupModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-dns-lookup/babel.config.js b/native-modules/react-native-dns-lookup/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-dns-lookup/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-dns-lookup/ios/DnsLookup.h b/native-modules/react-native-dns-lookup/ios/DnsLookup.h new file mode 100644 index 00000000..d6efb6c6 --- /dev/null +++ b/native-modules/react-native-dns-lookup/ios/DnsLookup.h @@ -0,0 +1,9 @@ +#import + +@interface DnsLookup : NativeDnsLookupSpecBase + +- (void)getIpAddresses:(NSString *)hostname + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-dns-lookup/ios/DnsLookup.mm b/native-modules/react-native-dns-lookup/ios/DnsLookup.mm new file mode 100644 index 00000000..3815eff4 --- /dev/null +++ b/native-modules/react-native-dns-lookup/ios/DnsLookup.mm @@ -0,0 +1,99 @@ +#import "DnsLookup.h" + +#import +#import +#import +#import +#import + +@implementation DnsLookup + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNDnsLookup"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - getIpAddresses + +- (void)getIpAddresses:(NSString *)hostname + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSError *error = nil; + NSArray *addresses = [self performDnsLookup:hostname error:&error]; + + if (addresses == nil) { + NSString *errorCode = [NSString stringWithFormat:@"%ld", (long)error.code]; + reject(errorCode, error.userInfo[NSDebugDescriptionErrorKey], error); + } else { + resolve(addresses); + } + }); +} + +// MARK: - DNS lookup helper + +- (NSArray *)performDnsLookup:(NSString *)hostname + error:(NSError **)error +{ + if (hostname == nil) { + *error = [NSError errorWithDomain:NSGenericException + code:kCFHostErrorUnknown + userInfo:@{NSDebugDescriptionErrorKey: @"Hostname cannot be null."}]; + return nil; + } + + CFHostRef hostRef = CFHostCreateWithName(kCFAllocatorDefault, (__bridge CFStringRef)hostname); + if (hostRef == nil) { + *error = [NSError errorWithDomain:NSGenericException + code:kCFHostErrorUnknown + userInfo:@{NSDebugDescriptionErrorKey: @"Failed to create host."}]; + return nil; + } + + BOOL didStart = CFHostStartInfoResolution(hostRef, kCFHostAddresses, nil); + if (!didStart) { + *error = [NSError errorWithDomain:NSGenericException + code:kCFHostErrorUnknown + userInfo:@{NSDebugDescriptionErrorKey: @"Failed to start."}]; + CFRelease(hostRef); + return nil; + } + + CFArrayRef addressesRef = CFHostGetAddressing(hostRef, nil); + if (addressesRef == nil) { + *error = [NSError errorWithDomain:NSGenericException + code:kCFHostErrorUnknown + userInfo:@{NSDebugDescriptionErrorKey: @"Failed to get addresses."}]; + CFRelease(hostRef); + return nil; + } + + NSMutableArray *addresses = [NSMutableArray array]; + char ipAddress[INET6_ADDRSTRLEN]; + CFIndex numAddresses = CFArrayGetCount(addressesRef); + + for (CFIndex currentIndex = 0; currentIndex < numAddresses; currentIndex++) { + struct sockaddr *address = (struct sockaddr *)CFDataGetBytePtr( + (CFDataRef)CFArrayGetValueAtIndex(addressesRef, currentIndex)); + getnameinfo(address, address->sa_len, ipAddress, INET6_ADDRSTRLEN, nil, 0, NI_NUMERICHOST); + [addresses addObject:[NSString stringWithCString:ipAddress encoding:NSASCIIStringEncoding]]; + } + + CFRelease(hostRef); + return addresses; +} + +@end diff --git a/native-modules/react-native-dns-lookup/package.json b/native-modules/react-native-dns-lookup/package.json new file mode 100644 index 00000000..0cf96d4b --- /dev/null +++ b/native-modules/react-native-dns-lookup/package.json @@ -0,0 +1,168 @@ +{ + "name": "@onekeyfe/react-native-dns-lookup", + "version": "3.0.18", + "description": "react-native-dns-lookup", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "*.podspec", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-dns-lookup.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-dns-lookup/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-dns-lookup#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNDnsLookupSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rnsdnslookup" + }, + "ios": { + "modulesProvider": { + "RNDnsLookup": "DnsLookup" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-dns-lookup/src/NativeDnsLookup.ts b/native-modules/react-native-dns-lookup/src/NativeDnsLookup.ts new file mode 100644 index 00000000..48cbaf4a --- /dev/null +++ b/native-modules/react-native-dns-lookup/src/NativeDnsLookup.ts @@ -0,0 +1,8 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + getIpAddresses(hostname: string): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNDnsLookup'); diff --git a/native-modules/react-native-dns-lookup/src/index.tsx b/native-modules/react-native-dns-lookup/src/index.tsx new file mode 100644 index 00000000..d52f6755 --- /dev/null +++ b/native-modules/react-native-dns-lookup/src/index.tsx @@ -0,0 +1,7 @@ +import NativeDnsLookup from './NativeDnsLookup'; + +export const DnsLookup = NativeDnsLookup; +export const getIpAddressesForHostname = ( + hostname: string, +): Promise => NativeDnsLookup.getIpAddresses(hostname); +export type { Spec as DnsLookupSpec } from './NativeDnsLookup'; diff --git a/native-modules/react-native-dns-lookup/tsconfig.build.json b/native-modules/react-native-dns-lookup/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-dns-lookup/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-dns-lookup/tsconfig.json b/native-modules/react-native-dns-lookup/tsconfig.json new file mode 100644 index 00000000..7359a8bf --- /dev/null +++ b/native-modules/react-native-dns-lookup/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-dns-lookup": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-dns-lookup/turbo.json b/native-modules/react-native-dns-lookup/turbo.json new file mode 100644 index 00000000..8b2bf087 --- /dev/null +++ b/native-modules/react-native-dns-lookup/turbo.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-get-random-values/package.json b/native-modules/react-native-get-random-values/package.json index 6c23847d..01d9a234 100644 --- a/native-modules/react-native-get-random-values/package.json +++ b/native-modules/react-native-get-random-values/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-get-random-values", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-get-random-values", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-keychain-module/package.json b/native-modules/react-native-keychain-module/package.json index d368879a..3dc41fae 100644 --- a/native-modules/react-native-keychain-module/package.json +++ b/native-modules/react-native-keychain-module/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-keychain-module", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-keychain-module", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-lite-card/package.json b/native-modules/react-native-lite-card/package.json index a27845a5..c73d5fc5 100644 --- a/native-modules/react-native-lite-card/package.json +++ b/native-modules/react-native-lite-card/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-lite-card", - "version": "1.1.46", + "version": "3.0.18", "description": "lite card", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -78,7 +79,7 @@ "lefthook": "^2.0.3", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "release-it": "^19.0.4", "turbo": "^2.5.6", @@ -112,6 +113,11 @@ "jsSrcsDir": "src", "android": { "javaPackageName": "com.onekeyfe.reactnativelitecard" + }, + "ios": { + "modulesProvider": { + "ReactNativeLiteCard": "ReactNativeLiteCard" + } } }, "prettier": { diff --git a/native-modules/react-native-network-info/NetworkInfo.podspec b/native-modules/react-native-network-info/NetworkInfo.podspec new file mode 100644 index 00000000..7b525800 --- /dev/null +++ b/native-modules/react-native-network-info/NetworkInfo.podspec @@ -0,0 +1,20 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "NetworkInfo" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-network-info.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift,c}" + s.frameworks = 'CoreLocation', 'SystemConfiguration', 'NetworkExtension' + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-network-info/README.md b/native-modules/react-native-network-info/README.md new file mode 100644 index 00000000..0304494f --- /dev/null +++ b/native-modules/react-native-network-info/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-network-info + +First, a sincere thank-you to the +`react-native-network-info` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[pusherman/react-native-network-info](https://github.com/pusherman/react-native-network-info). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-network-info`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-network-info` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [pusherman/react-native-network-info](https://github.com/pusherman/react-native-network-info) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to everyone who contributes to +`react-native-network-info` 💙 diff --git a/native-modules/react-native-network-info/android/build.gradle b/native-modules/react-native-network-info/android/build.gradle new file mode 100644 index 00000000..8433aae7 --- /dev/null +++ b/native-modules/react-native-network-info/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['NetworkInfo_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["NetworkInfo_" + name]).toInteger() +} + +android { + namespace "com.rnnetworkinfo" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoModule.kt b/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoModule.kt new file mode 100644 index 00000000..3f39928a --- /dev/null +++ b/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoModule.kt @@ -0,0 +1,292 @@ +package com.rnnetworkinfo + +import android.content.Context +import android.net.wifi.SupplicantState +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import java.net.InterfaceAddress +import java.net.NetworkInterface + +/** + * Ported from upstream react-native-network-info RNNetworkInfo.java. + * Adapted to extend NativeRNNetworkInfoSpec (TurboModule). + */ +@ReactModule(name = NetworkInfoModule.NAME) +class NetworkInfoModule(reactContext: ReactApplicationContext) : + NativeNetworkInfoSpec(reactContext) { + + companion object { + const val NAME = "RNNetworkInfo" + + val DSLITE_LIST = listOf( + "192.0.0.0", "192.0.0.1", "192.0.0.2", "192.0.0.3", + "192.0.0.4", "192.0.0.5", "192.0.0.6", "192.0.0.7" + ) + } + + private val wifi: WifiManager = + reactContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + override fun getName(): String = NAME + + // MARK: - getSSID + + override fun getSSID(promise: Promise) { + Thread { + try { + @Suppress("DEPRECATION") + val info: WifiInfo = wifi.connectionInfo + var ssid: String? = null + if (info.supplicantState == SupplicantState.COMPLETED) { + @Suppress("DEPRECATION") + ssid = info.ssid + if (ssid != null && ssid.startsWith("\"") && ssid.endsWith("\"")) { + ssid = ssid.substring(1, ssid.length - 1) + } + } + promise.resolve(ssid) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getBSSID + + override fun getBSSID(promise: Promise) { + Thread { + try { + @Suppress("DEPRECATION") + val info: WifiInfo = wifi.connectionInfo + var bssid: String? = null + if (info.supplicantState == SupplicantState.COMPLETED) { + @Suppress("DEPRECATION") + bssid = wifi.connectionInfo.bssid + } + promise.resolve(bssid) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getBroadcast + + override fun getBroadcast(promise: Promise) { + Thread { + try { + var ipAddress: String? = null + for (address in getInetAddresses()) { + if (!address.address.isLoopbackAddress) { + val broadCast: InetAddress? = address.broadcast + if (broadCast != null) { + ipAddress = broadCast.toString() + } + } + } + promise.resolve(ipAddress) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getIPAddress + + override fun getIPAddress(promise: Promise) { + Thread { + try { + var ipAddress: String? = null + var tmp = "0.0.0.0" + for (address in getInetAddresses()) { + if (!address.address.isLoopbackAddress) { + tmp = address.address.hostAddress.toString() + if (!inDSLITERange(tmp)) { + ipAddress = tmp + } + } + } + promise.resolve(ipAddress) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getIPV4Address + + override fun getIPV4Address(promise: Promise) { + Thread { + try { + var ipAddress: String? = null + var tmp = "0.0.0.0" + for (address in getInetAddresses()) { + if (!address.address.isLoopbackAddress && address.address is Inet4Address) { + tmp = address.address.hostAddress.toString() + if (!inDSLITERange(tmp)) { + ipAddress = tmp + } + } + } + promise.resolve(ipAddress) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getIPV6Address + + override fun getIPV6Address(promise: Promise) { + Thread { + try { + var address: String? = null + val interfaces = NetworkInterface.getNetworkInterfaces() + loop@ while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (!iface.isUp || iface.isLoopback) continue + val addrs = iface.inetAddresses + while (addrs.hasMoreElements()) { + val addr = addrs.nextElement() + if (addr is Inet6Address && !addr.isLoopbackAddress && !addr.isLinkLocalAddress) { + address = addr.hostAddress + break@loop + } + } + } + promise.resolve(address ?: "") + } catch (e: Exception) { + promise.resolve("") + } + }.start() + } + + // MARK: - getWIFIIPV4Address + + override fun getWIFIIPV4Address(promise: Promise) { + Thread { + try { + @Suppress("DEPRECATION") + val info: WifiInfo = wifi.connectionInfo + val ipAddress = info.ipAddress + val stringIp = String.format( + "%d.%d.%d.%d", + ipAddress and 0xff, + ipAddress shr 8 and 0xff, + ipAddress shr 16 and 0xff, + ipAddress shr 24 and 0xff + ) + promise.resolve(stringIp) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getSubnet + + override fun getSubnet(promise: Promise) { + Thread { + try { + val interfaces = NetworkInterface.getNetworkInterfaces() + while (interfaces.hasMoreElements()) { + val iface = interfaces.nextElement() + if (iface.isLoopback || !iface.isUp) continue + + val addresses = iface.inetAddresses + for (address in iface.interfaceAddresses) { + val addr = addresses.nextElement() + if (addr is Inet6Address) continue + + promise.resolve(intToIP(address.networkPrefixLength.toInt())) + return@Thread + } + } + promise.resolve("0.0.0.0") + } catch (e: Exception) { + promise.resolve("0.0.0.0") + } + }.start() + } + + // MARK: - getGatewayIPAddress + + override fun getGatewayIPAddress(promise: Promise) { + Thread { + try { + @Suppress("DEPRECATION") + val dhcpInfo = wifi.dhcpInfo + val gatewayIPInt = dhcpInfo.gateway + val gatewayIP = String.format( + "%d.%d.%d.%d", + gatewayIPInt and 0xFF, + gatewayIPInt shr 8 and 0xFF, + gatewayIPInt shr 16 and 0xFF, + gatewayIPInt shr 24 and 0xFF + ) + promise.resolve(gatewayIP) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // MARK: - getFrequency + + override fun getFrequency(promise: Promise) { + Thread { + try { + @Suppress("DEPRECATION") + val info: WifiInfo = wifi.connectionInfo + val frequency = info.frequency.toDouble() + promise.resolve(frequency) + } catch (e: Exception) { + promise.resolve(null) + } + }.start() + } + + // --- Private helpers (ported from upstream RNNetworkInfo.java) --- + + private fun intToIP(ip: Int): String { + val finl = arrayOf("", "", "", "") + var k = 1 + for (i in 0 until 4) { + for (j in 0 until 8) { + if (k <= ip) { + finl[i] += "1" + } else { + finl[i] += "0" + } + k++ + } + } + return "${Integer.parseInt(finl[0], 2)}.${Integer.parseInt(finl[1], 2)}.${Integer.parseInt(finl[2], 2)}.${Integer.parseInt(finl[3], 2)}" + } + + private fun inDSLITERange(ip: String): Boolean { + return DSLITE_LIST.contains(ip) + } + + private fun getInetAddresses(): List { + val addresses = mutableListOf() + try { + val en = NetworkInterface.getNetworkInterfaces() + while (en.hasMoreElements()) { + val intf = en.nextElement() + for (interfaceAddress in intf.interfaceAddresses) { + addresses.add(interfaceAddress) + } + } + } catch (ex: Exception) { + android.util.Log.e(NAME, ex.toString()) + } + return addresses + } +} diff --git a/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoPackage.kt b/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoPackage.kt new file mode 100644 index 00000000..6670828e --- /dev/null +++ b/native-modules/react-native-network-info/android/src/main/java/com/rnnetworkinfo/NetworkInfoPackage.kt @@ -0,0 +1,33 @@ +package com.rnnetworkinfo + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class NetworkInfoPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == NetworkInfoModule.NAME) { + NetworkInfoModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[NetworkInfoModule.NAME] = ReactModuleInfo( + NetworkInfoModule.NAME, + NetworkInfoModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-network-info/babel.config.js b/native-modules/react-native-network-info/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-network-info/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-network-info/ios/NetworkInfo.h b/native-modules/react-native-network-info/ios/NetworkInfo.h new file mode 100644 index 00000000..00807ae1 --- /dev/null +++ b/native-modules/react-native-network-info/ios/NetworkInfo.h @@ -0,0 +1,26 @@ +#import + +@interface NetworkInfo : NativeNetworkInfoSpecBase + +- (void)getSSID:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getBSSID:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getBroadcast:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getIPAddress:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getIPV6Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getGatewayIPAddress:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getIPV4Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getWIFIIPV4Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getSubnet:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)getFrequency:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-network-info/ios/NetworkInfo.mm b/native-modules/react-native-network-info/ios/NetworkInfo.mm new file mode 100644 index 00000000..de51ae7a --- /dev/null +++ b/native-modules/react-native-network-info/ios/NetworkInfo.mm @@ -0,0 +1,356 @@ +#import "NetworkInfo.h" +#import "getgateway.h" + +#import +#import +#include +#include +#include +#include +#import + +#define IOS_CELLULAR @"pdp_ip0" +#define IOS_WIFI @"en0" +#define IP_ADDR_IPv4 @"ipv4" +#define IP_ADDR_IPv6 @"ipv6" + +#import + +@implementation NetworkInfo + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNNetworkInfo"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - getSSID + +- (void)getSSID:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ +#if TARGET_OS_IOS + if (@available(iOS 14.0, *)) { + [NEHotspotNetwork fetchCurrentWithCompletionHandler:^(NEHotspotNetwork * _Nullable currentNetwork) { + resolve(currentNetwork.SSID); + }]; + return; + } + + @try { + NSArray *interfaceNames = CFBridgingRelease(CNCopySupportedInterfaces()); + NSDictionary *SSIDInfo; + NSString *SSID = nil; + + for (NSString *interfaceName in interfaceNames) { + SSIDInfo = CFBridgingRelease(CNCopyCurrentNetworkInfo((__bridge CFStringRef)interfaceName)); + if (SSIDInfo.count > 0) { + SSID = SSIDInfo[@"SSID"]; + break; + } + } + resolve(SSID); + } @catch (NSException *exception) { + resolve(nil); + } +#else + resolve(nil); +#endif +} + +// MARK: - getBSSID + +- (void)getBSSID:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ +#if TARGET_OS_IOS + if (@available(iOS 14.0, *)) { + [NEHotspotNetwork fetchCurrentWithCompletionHandler:^(NEHotspotNetwork * _Nullable currentNetwork) { + resolve(currentNetwork.BSSID); + }]; + return; + } + + @try { + NSArray *interfaceNames = CFBridgingRelease(CNCopySupportedInterfaces()); + NSString *BSSID = nil; + + for (NSString *interface in interfaceNames) { + CFDictionaryRef networkDetails = CNCopyCurrentNetworkInfo((CFStringRef)interface); + if (networkDetails) { + BSSID = (NSString *)CFDictionaryGetValue(networkDetails, kCNNetworkInfoKeyBSSID); + CFRelease(networkDetails); + } + } + resolve(BSSID); + } @catch (NSException *exception) { + resolve(nil); + } +#else + resolve(nil); +#endif +} + +// MARK: - getBroadcast + +- (void)getBroadcast:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *address = nil; + + struct ifaddrs *interfaces = NULL; + struct ifaddrs *temp_addr = NULL; + int success = getifaddrs(&interfaces); + + if (success == 0) { + temp_addr = interfaces; + while (temp_addr != NULL) { + if (temp_addr->ifa_addr->sa_family == AF_INET) { + if ([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"en0"]) { + NSString *localAddr = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_addr)->sin_addr)]; + NSString *netmask = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_netmask)->sin_addr)]; + + struct in_addr local_addr; + struct in_addr netmask_addr; + inet_aton([localAddr UTF8String], &local_addr); + inet_aton([netmask UTF8String], &netmask_addr); + + local_addr.s_addr |= ~(netmask_addr.s_addr); + address = [NSString stringWithUTF8String:inet_ntoa(local_addr)]; + } + } + temp_addr = temp_addr->ifa_next; + } + } + freeifaddrs(interfaces); + resolve(address); + } @catch (NSException *exception) { + resolve(nil); + } + }); +} + +// MARK: - getIPAddress + +- (void)getIPAddress:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *address = nil; + + struct ifaddrs *interfaces = NULL; + struct ifaddrs *temp_addr = NULL; + int success = getifaddrs(&interfaces); + + if (success == 0) { + temp_addr = interfaces; + while (temp_addr != NULL) { + if (temp_addr->ifa_addr->sa_family == AF_INET) { + if ([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"en0"]) { + address = [NSString stringWithUTF8String:inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_addr)->sin_addr)]; + } + } + temp_addr = temp_addr->ifa_next; + } + } + freeifaddrs(interfaces); + resolve(address ?: @""); + } @catch (NSException *exception) { + resolve(@""); + } + }); +} + +// MARK: - getIPV6Address + +- (void)getIPV6Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *address = nil; + + struct ifaddrs *interfaces = NULL; + struct ifaddrs *temp_addr = NULL; + int success = getifaddrs(&interfaces); + + if (success == 0) { + temp_addr = interfaces; + while (temp_addr != NULL) { + if (temp_addr->ifa_addr->sa_family == AF_INET6) { + if ([[NSString stringWithUTF8String:temp_addr->ifa_name] isEqualToString:@"en0"]) { + char ipAddress[INET6_ADDRSTRLEN]; + struct sockaddr_in6 *addr6 = (struct sockaddr_in6 *)temp_addr->ifa_addr; + if (inet_ntop(AF_INET6, &addr6->sin6_addr, ipAddress, INET6_ADDRSTRLEN)) { + address = [NSString stringWithUTF8String:ipAddress]; + } + } + } + temp_addr = temp_addr->ifa_next; + } + } + freeifaddrs(interfaces); + resolve(address ?: @""); + } @catch (NSException *exception) { + resolve(@""); + } + }); +} + +// MARK: - getGatewayIPAddress + +- (void)getGatewayIPAddress:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *ipString = nil; + struct in_addr gatewayaddr; + int r = getdefaultgateway(&(gatewayaddr.s_addr)); + if (r >= 0) { + ipString = [NSString stringWithFormat:@"%s", inet_ntoa(gatewayaddr)]; + } + resolve(ipString ?: @""); + } @catch (NSException *exception) { + resolve(@""); + } + }); +} + +// MARK: - getIPV4Address + +- (void)getIPV4Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSArray *searchArray = @[IOS_WIFI @"/" IP_ADDR_IPv4, IOS_CELLULAR @"/" IP_ADDR_IPv4]; + NSDictionary *addresses = [self getAllIPAddresses]; + + __block NSString *address = nil; + [searchArray enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) { + address = addresses[key]; + if (address) *stop = YES; + }]; + resolve(address ?: @"0.0.0.0"); + } @catch (NSException *exception) { + resolve(@"0.0.0.0"); + } + }); +} + +// MARK: - getWIFIIPV4Address + +- (void)getWIFIIPV4Address:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSArray *searchArray = @[IOS_WIFI @"/" IP_ADDR_IPv4]; + NSDictionary *addresses = [self getAllIPAddresses]; + + __block NSString *address = nil; + [searchArray enumerateObjectsUsingBlock:^(NSString *key, NSUInteger idx, BOOL *stop) { + address = addresses[key]; + if (address) *stop = YES; + }]; + resolve(address ?: @"0.0.0.0"); + } @catch (NSException *exception) { + resolve(@"0.0.0.0"); + } + }); +} + +// MARK: - getSubnet + +- (void)getSubnet:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSString *netmask = nil; + struct ifaddrs *interfaces = NULL; + struct ifaddrs *temp_addr = NULL; + + int success = getifaddrs(&interfaces); + if (success == 0) { + temp_addr = interfaces; + while (temp_addr != NULL) { + if (temp_addr->ifa_addr->sa_family == AF_INET) { + if ([@(temp_addr->ifa_name) isEqualToString:@"en0"]) { + netmask = @(inet_ntoa(((struct sockaddr_in *)temp_addr->ifa_netmask)->sin_addr)); + } + } + temp_addr = temp_addr->ifa_next; + } + } + freeifaddrs(interfaces); + resolve(netmask ?: @"0.0.0.0"); + } @catch (NSException *exception) { + resolve(@"0.0.0.0"); + } + }); +} + +// MARK: - getFrequency + +- (void)getFrequency:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + // WiFi frequency is not available on iOS + resolve(nil); +} + +// MARK: - getAllIPAddresses helper + +- (NSDictionary *)getAllIPAddresses +{ + NSMutableDictionary *addresses = [NSMutableDictionary dictionaryWithCapacity:8]; + + struct ifaddrs *interfaces; + if (!getifaddrs(&interfaces)) { + struct ifaddrs *interface; + for (interface = interfaces; interface; interface = interface->ifa_next) { + if (!(interface->ifa_flags & IFF_UP)) { + continue; + } + const struct sockaddr_in *addr = (const struct sockaddr_in *)interface->ifa_addr; + char addrBuf[MAX(INET_ADDRSTRLEN, INET6_ADDRSTRLEN)]; + if (addr && (addr->sin_family == AF_INET || addr->sin_family == AF_INET6)) { + NSString *name = [NSString stringWithUTF8String:interface->ifa_name]; + NSString *type = nil; + if (addr->sin_family == AF_INET) { + if (inet_ntop(AF_INET, &addr->sin_addr, addrBuf, INET_ADDRSTRLEN)) { + type = IP_ADDR_IPv4; + } + } else { + const struct sockaddr_in6 *addr6 = (const struct sockaddr_in6 *)interface->ifa_addr; + if (inet_ntop(AF_INET6, &addr6->sin6_addr, addrBuf, INET6_ADDRSTRLEN)) { + type = IP_ADDR_IPv6; + } + } + if (type) { + NSString *key = [NSString stringWithFormat:@"%@/%@", name, type]; + addresses[key] = [NSString stringWithUTF8String:addrBuf]; + } + } + } + freeifaddrs(interfaces); + } + return [addresses count] ? addresses : @{}; +} + +@end diff --git a/native-modules/react-native-network-info/ios/getgateway.c b/native-modules/react-native-network-info/ios/getgateway.c new file mode 100644 index 00000000..22af591d --- /dev/null +++ b/native-modules/react-native-network-info/ios/getgateway.c @@ -0,0 +1,117 @@ +// +// getgateway.c +// +// This is pulled directly from https://stackoverflow.com/a/29440193/1120802 +// + +#include +#include +#include +#include +#include "getgateway.h" + +#include "TargetConditionals.h" +#if TARGET_IPHONE_SIMULATOR +#define TypeEN "en1" +#else +#define TypeEN "en0" +#endif + +#include + +// Inline route.h definitions (not available in iOS SDK) +#define RTF_GATEWAY 0x2 +#define NET_RT_FLAGS 2 +#define RTAX_MAX 8 +#define RTAX_DST 0 +#define RTAX_GATEWAY 1 +#define RTA_DST 0x1 +#define RTA_GATEWAY 0x2 + +struct rt_msghdr { + u_short rtm_msglen; + u_char rtm_version; + u_char rtm_type; + u_short rtm_index; + int rtm_flags; + int rtm_addrs; + pid_t rtm_pid; + int rtm_seq; + int rtm_errno; + int rtm_use; + u_long rtm_inits; + struct rt_metrics { + u_long rmx_locks; + u_long rmx_mtu; + u_long rmx_hopcount; + u_long rmx_expire; + u_long rmx_recvpipe; + u_long rmx_sendpipe; + u_long rmx_ssthresh; + u_long rmx_rtt; + u_long rmx_rttvar; + u_long rmx_pksent; + u_long rmx_filler[4]; + } rtm_rmx; +}; +#include + +#define CTL_NET 4 /* network, see socket.h */ + +#if defined(BSD) || defined(__APPLE__) + +#define ROUNDUP(a) \ +((a) > 0 ? (1 + (((a) - 1) | (sizeof(long) - 1))) : sizeof(long)) + +int getdefaultgateway(in_addr_t *addr) +{ + int mib[] = {CTL_NET, PF_ROUTE, 0, AF_INET, + NET_RT_FLAGS, RTF_GATEWAY}; + size_t l; + char *buf, *p; + struct rt_msghdr *rt; + struct sockaddr *sa; + struct sockaddr *sa_tab[RTAX_MAX]; + int i; + int r = -1; + if (sysctl(mib, sizeof(mib) / sizeof(int), 0, &l, 0, 0) < 0) { + return -1; + } + if (l > 0) { + buf = malloc(l); + if (sysctl(mib, sizeof(mib) / sizeof(int), buf, &l, 0, 0) < 0) { + free(buf); + return -1; + } + for (p = buf; p < buf + l; p += rt->rtm_msglen) { + rt = (struct rt_msghdr *)p; + sa = (struct sockaddr *)(rt + 1); + for (i = 0; i < RTAX_MAX; i++) { + if (rt->rtm_addrs & (1 << i)) { + sa_tab[i] = sa; + sa = (struct sockaddr *)((char *)sa + ROUNDUP(sa->sa_len)); + } else { + sa_tab[i] = NULL; + } + } + + if (((rt->rtm_addrs & (RTA_DST | RTA_GATEWAY)) == (RTA_DST | RTA_GATEWAY)) + && sa_tab[RTAX_DST]->sa_family == AF_INET + && sa_tab[RTAX_GATEWAY]->sa_family == AF_INET) { + + if (((struct sockaddr_in *)sa_tab[RTAX_DST])->sin_addr.s_addr == 0) { + char ifName[128]; + if_indextoname(rt->rtm_index, ifName); + if (strcmp(TypeEN, ifName) == 0) { + *addr = ((struct sockaddr_in *)(sa_tab[RTAX_GATEWAY]))->sin_addr.s_addr; + r = 0; + } + } + } + } + free(buf); + } + return r; +} + +#endif diff --git a/native-modules/react-native-network-info/ios/getgateway.h b/native-modules/react-native-network-info/ios/getgateway.h new file mode 100644 index 00000000..09fab260 --- /dev/null +++ b/native-modules/react-native-network-info/ios/getgateway.h @@ -0,0 +1,23 @@ +// +// getgateway.h +// +// This is pulled directly from https://stackoverflow.com/a/29440193/1120802 +// + +#ifndef getgateway_h +#define getgateway_h + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +int getdefaultgateway(in_addr_t *addr); + +#ifdef __cplusplus +} +#endif + +#endif /* getgateway_h */ diff --git a/native-modules/react-native-network-info/package.json b/native-modules/react-native-network-info/package.json new file mode 100644 index 00000000..b7acad05 --- /dev/null +++ b/native-modules/react-native-network-info/package.json @@ -0,0 +1,168 @@ +{ + "name": "@onekeyfe/react-native-network-info", + "version": "3.0.18", + "description": "react-native-network-info", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "*.podspec", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-network-info.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-network-info/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-network-info#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNNetworkInfoSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rnnetworkinfo" + }, + "ios": { + "modulesProvider": { + "RNNetworkInfo": "NetworkInfo" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-network-info/src/NativeNetworkInfo.ts b/native-modules/react-native-network-info/src/NativeNetworkInfo.ts new file mode 100644 index 00000000..fe26a7a0 --- /dev/null +++ b/native-modules/react-native-network-info/src/NativeNetworkInfo.ts @@ -0,0 +1,17 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + getSSID(): Promise; + getBSSID(): Promise; + getBroadcast(): Promise; + getIPAddress(): Promise; + getIPV6Address(): Promise; + getGatewayIPAddress(): Promise; + getIPV4Address(): Promise; + getWIFIIPV4Address(): Promise; + getSubnet(): Promise; + getFrequency(): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNNetworkInfo'); diff --git a/native-modules/react-native-network-info/src/index.tsx b/native-modules/react-native-network-info/src/index.tsx new file mode 100644 index 00000000..d7328bea --- /dev/null +++ b/native-modules/react-native-network-info/src/index.tsx @@ -0,0 +1,4 @@ +import NativeNetworkInfo from './NativeNetworkInfo'; + +export const NetworkInfo = NativeNetworkInfo; +export type { Spec as NetworkInfoSpec } from './NativeNetworkInfo'; diff --git a/native-modules/react-native-network-info/tsconfig.build.json b/native-modules/react-native-network-info/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-network-info/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-network-info/tsconfig.json b/native-modules/react-native-network-info/tsconfig.json new file mode 100644 index 00000000..4d9ef8bb --- /dev/null +++ b/native-modules/react-native-network-info/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-network-info": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-network-info/turbo.json b/native-modules/react-native-network-info/turbo.json new file mode 100644 index 00000000..8b2bf087 --- /dev/null +++ b/native-modules/react-native-network-info/turbo.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-pbkdf2/Pbkdf2.podspec b/native-modules/react-native-pbkdf2/Pbkdf2.podspec new file mode 100644 index 00000000..fa06c778 --- /dev/null +++ b/native-modules/react-native-pbkdf2/Pbkdf2.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "Pbkdf2" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-pbkdf2.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-pbkdf2/README.md b/native-modules/react-native-pbkdf2/README.md new file mode 100644 index 00000000..caf9e308 --- /dev/null +++ b/native-modules/react-native-pbkdf2/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-pbkdf2 + +First, a sincere thank-you to the +`react-native-pbkdf2` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[nicola/react-native-pbkdf2](https://github.com/nicola/react-native-pbkdf2). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-pbkdf2`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-pbkdf2` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [nicola/react-native-pbkdf2](https://github.com/nicola/react-native-pbkdf2) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to everyone who contributes to +`react-native-pbkdf2` 💙 diff --git a/native-modules/react-native-pbkdf2/android/build.gradle b/native-modules/react-native-pbkdf2/android/build.gradle new file mode 100644 index 00000000..64a3b16e --- /dev/null +++ b/native-modules/react-native-pbkdf2/android/build.gradle @@ -0,0 +1,80 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['Pbkdf2_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["Pbkdf2_" + name]).toInteger() +} + +android { + namespace "com.pbkdf2" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.madgag.spongycastle:core:1.58.0.0" + implementation "com.madgag.spongycastle:prov:1.54.0.0" + implementation "com.madgag.spongycastle:pg:1.54.0.0" +} diff --git a/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Module.kt b/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Module.kt new file mode 100644 index 00000000..f2b13472 --- /dev/null +++ b/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Module.kt @@ -0,0 +1,55 @@ +package com.pbkdf2 + +import android.util.Base64 +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import org.spongycastle.crypto.Digest +import org.spongycastle.crypto.digests.SHA1Digest +import org.spongycastle.crypto.digests.SHA256Digest +import org.spongycastle.crypto.digests.SHA512Digest +import org.spongycastle.crypto.generators.PKCS5S2ParametersGenerator +import org.spongycastle.crypto.params.KeyParameter + +/** + * Ported from upstream react-native-fast-pbkdf2 Pbkdf2Module.java. + * Adapted to extend NativePbkdf2Spec (TurboModule). + */ +@ReactModule(name = Pbkdf2Module.NAME) +class Pbkdf2Module(reactContext: ReactApplicationContext) : + NativePbkdf2Spec(reactContext) { + + companion object { + const val NAME = "Pbkdf2" + } + + override fun getName(): String = NAME + + override fun derive( + password: String, + salt: String, + rounds: Double, + keyLength: Double, + hash: String, + promise: Promise + ) { + try { + val decodedPassword = Base64.decode(password, Base64.DEFAULT) + val decodedSalt = Base64.decode(salt, Base64.DEFAULT) + + // Default to SHA1 (matching upstream original) + val digest: Digest = when (hash) { + "sha-256" -> SHA256Digest() + "sha-512" -> SHA512Digest() + else -> SHA1Digest() + } + + val gen = PKCS5S2ParametersGenerator(digest) + gen.init(decodedPassword, decodedSalt, rounds.toInt()) + val key = (gen.generateDerivedParameters(keyLength.toInt() * 8) as KeyParameter).key + promise.resolve(Base64.encodeToString(key, Base64.DEFAULT)) + } catch (e: Exception) { + promise.reject("PBKDF2_ERROR", e.message, e) + } + } +} diff --git a/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Package.kt b/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Package.kt new file mode 100644 index 00000000..bcaf2030 --- /dev/null +++ b/native-modules/react-native-pbkdf2/android/src/main/java/com/pbkdf2/Pbkdf2Package.kt @@ -0,0 +1,33 @@ +package com.pbkdf2 + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class Pbkdf2Package : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == Pbkdf2Module.NAME) { + Pbkdf2Module(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[Pbkdf2Module.NAME] = ReactModuleInfo( + Pbkdf2Module.NAME, + Pbkdf2Module.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-pbkdf2/babel.config.js b/native-modules/react-native-pbkdf2/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-pbkdf2/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-pbkdf2/ios/Pbkdf2.h b/native-modules/react-native-pbkdf2/ios/Pbkdf2.h new file mode 100644 index 00000000..1c5a0597 --- /dev/null +++ b/native-modules/react-native-pbkdf2/ios/Pbkdf2.h @@ -0,0 +1,13 @@ +#import + +@interface Pbkdf2 : NativePbkdf2SpecBase + +- (void)derive:(NSString *)password + salt:(NSString *)salt + rounds:(double)rounds + keyLength:(double)keyLength + hash:(NSString *)hash + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-pbkdf2/ios/Pbkdf2.mm b/native-modules/react-native-pbkdf2/ios/Pbkdf2.mm new file mode 100644 index 00000000..c9ac72e7 --- /dev/null +++ b/native-modules/react-native-pbkdf2/ios/Pbkdf2.mm @@ -0,0 +1,62 @@ +#import "Pbkdf2.h" +#import + +@implementation Pbkdf2 + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"Pbkdf2"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - derive + +- (void)derive:(NSString *)password + salt:(NSString *)salt + rounds:(double)rounds + keyLength:(double)keyLength + hash:(NSString *)hash + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + NSData *passwordData = [[NSData alloc] initWithBase64EncodedString:password options:0]; + NSData *saltData = [[NSData alloc] initWithBase64EncodedString:salt options:0]; + + NSUInteger keyLengthBytes = (NSUInteger)keyLength; + NSMutableData *derivedKey = [NSMutableData dataWithLength:keyLengthBytes]; + + CCPseudoRandomAlgorithm prf = kCCPRFHmacAlgSHA1; + if ([hash isEqualToString:@"sha-512"]) { + prf = kCCPRFHmacAlgSHA512; + } else if ([hash isEqualToString:@"sha-256"]) { + prf = kCCPRFHmacAlgSHA256; + } + + CCKeyDerivationPBKDF( + kCCPBKDF2, + (const char *)passwordData.bytes, passwordData.length, + (const uint8_t *)saltData.bytes, saltData.length, + prf, + (unsigned int)rounds, + (uint8_t *)derivedKey.mutableBytes, derivedKey.length); + + resolve([derivedKey base64EncodedStringWithOptions:0]); + } @catch (NSException *exception) { + reject(@"derive_fail", exception.reason, nil); + } + }); +} + +@end diff --git a/native-modules/react-native-pbkdf2/package.json b/native-modules/react-native-pbkdf2/package.json new file mode 100644 index 00000000..525ebd94 --- /dev/null +++ b/native-modules/react-native-pbkdf2/package.json @@ -0,0 +1,162 @@ +{ + "name": "@onekeyfe/react-native-pbkdf2", + "version": "3.0.18", + "description": "react-native-pbkdf2", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-pbkdf2.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-pbkdf2/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-pbkdf2#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "Pbkdf2Spec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.pbkdf2" + }, + "ios": { + "modulesProvider": { + "Pbkdf2": "Pbkdf2" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-pbkdf2/src/NativePbkdf2.ts b/native-modules/react-native-pbkdf2/src/NativePbkdf2.ts new file mode 100644 index 00000000..a14902d0 --- /dev/null +++ b/native-modules/react-native-pbkdf2/src/NativePbkdf2.ts @@ -0,0 +1,14 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + derive( + password: string, + salt: string, + rounds: number, + keyLength: number, + hash: string, + ): Promise; +} + +export default TurboModuleRegistry.getEnforcing('Pbkdf2'); diff --git a/native-modules/react-native-pbkdf2/src/index.tsx b/native-modules/react-native-pbkdf2/src/index.tsx new file mode 100644 index 00000000..5799aad8 --- /dev/null +++ b/native-modules/react-native-pbkdf2/src/index.tsx @@ -0,0 +1,4 @@ +import NativePbkdf2 from './NativePbkdf2'; + +export default NativePbkdf2; +export type { Spec as Pbkdf2Spec } from './NativePbkdf2'; diff --git a/native-modules/react-native-pbkdf2/tsconfig.build.json b/native-modules/react-native-pbkdf2/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-pbkdf2/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-pbkdf2/tsconfig.json b/native-modules/react-native-pbkdf2/tsconfig.json new file mode 100644 index 00000000..08b64104 --- /dev/null +++ b/native-modules/react-native-pbkdf2/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-pbkdf2": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-pbkdf2/turbo.json b/native-modules/react-native-pbkdf2/turbo.json new file mode 100644 index 00000000..e094f154 --- /dev/null +++ b/native-modules/react-native-pbkdf2/turbo.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-perf-memory/package.json b/native-modules/react-native-perf-memory/package.json index 74804866..0bd9a361 100644 --- a/native-modules/react-native-perf-memory/package.json +++ b/native-modules/react-native-perf-memory/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-perf-memory", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-perf-memory", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-ping/Ping.podspec b/native-modules/react-native-ping/Ping.podspec new file mode 100644 index 00000000..6604500d --- /dev/null +++ b/native-modules/react-native-ping/Ping.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "Ping" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-ping.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,c}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-ping/README.md b/native-modules/react-native-ping/README.md new file mode 100644 index 00000000..c761be63 --- /dev/null +++ b/native-modules/react-native-ping/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-ping + +First, a sincere thank-you to the +`react-native-ping` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[nicola/react-native-ping](https://github.com/nicola/react-native-ping). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-ping`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-ping` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [nicola/react-native-ping](https://github.com/nicola/react-native-ping) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to everyone who contributes to +`react-native-ping` 💙 diff --git a/native-modules/react-native-ping/android/build.gradle b/native-modules/react-native-ping/android/build.gradle new file mode 100644 index 00000000..56cee1c7 --- /dev/null +++ b/native-modules/react-native-ping/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNPing_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNPing_" + name]).toInteger() +} + +android { + namespace "com.rnping" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingModule.kt b/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingModule.kt new file mode 100644 index 00000000..dbea0219 --- /dev/null +++ b/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingModule.kt @@ -0,0 +1,37 @@ +package com.rnping + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.module.annotations.ReactModule +import java.net.InetAddress + +@ReactModule(name = RNReactNativePingModule.NAME) +class RNReactNativePingModule(reactContext: ReactApplicationContext) : + NativePingSpec(reactContext) { + + companion object { + const val NAME = "RNReactNativePing" + private const val DEFAULT_TIMEOUT_MS = 3000 + } + + override fun getName(): String = NAME + + override fun start(ipAddress: String, option: ReadableMap, promise: Promise) { + Thread { + try { + val timeout = if (option.hasKey("timeout")) option.getInt("timeout") else DEFAULT_TIMEOUT_MS + val startTime = System.currentTimeMillis() + val reachable = InetAddress.getByName(ipAddress).isReachable(timeout) + val elapsed = System.currentTimeMillis() - startTime + if (reachable) { + promise.resolve(elapsed.toDouble()) + } else { + promise.reject("PING_TIMEOUT", "Host $ipAddress is not reachable within ${timeout}ms") + } + } catch (e: Exception) { + promise.reject("PING_ERROR", e.message, e) + } + }.start() + } +} diff --git a/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingPackage.kt b/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingPackage.kt new file mode 100644 index 00000000..353ff3a6 --- /dev/null +++ b/native-modules/react-native-ping/android/src/main/java/com/rnping/RNReactNativePingPackage.kt @@ -0,0 +1,33 @@ +package com.rnping + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class RNReactNativePingPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == RNReactNativePingModule.NAME) { + RNReactNativePingModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[RNReactNativePingModule.NAME] = ReactModuleInfo( + RNReactNativePingModule.NAME, + RNReactNativePingModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-ping/babel.config.js b/native-modules/react-native-ping/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-ping/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-ping/ios/GBPing/GBPing.h b/native-modules/react-native-ping/ios/GBPing/GBPing.h new file mode 100755 index 00000000..fd17491c --- /dev/null +++ b/native-modules/react-native-ping/ios/GBPing/GBPing.h @@ -0,0 +1,50 @@ +// +// GBPing.h +// + +#import + +#import "GBPingSummary.h" + +@class GBPingSummary; +@protocol GBPingDelegate; + +NS_ASSUME_NONNULL_BEGIN + +typedef void (^StartupCallback)(BOOL success, NSError *_Nullable error); + +@interface GBPing : NSObject + +@property (weak, nonatomic, nullable) id delegate; + +@property (copy, nonatomic, nullable) NSString *host; +@property (assign, atomic) NSTimeInterval pingPeriod; +@property (assign, atomic) NSTimeInterval timeout; +@property (assign, atomic) NSUInteger payloadSize; +@property (assign, atomic) NSUInteger ttl; +@property (assign, atomic, readonly) BOOL isPinging; +@property (assign, atomic, readonly) BOOL isReady; + +@property (assign, atomic) BOOL debug; + +- (void)setupWithBlock:(StartupCallback)callback; +- (void)startPingingWithBlock:(void (^)(GBPingSummary *))successBlock fail:(void (^)(NSError *))failBlock; +- (void)stop; + +@end + +@protocol GBPingDelegate + +@optional + +- (void)ping:(GBPing *)pinger didFailWithError:(NSError *)error; + +- (void)ping:(GBPing *)pinger didSendPingWithSummary:(GBPingSummary *)summary; +- (void)ping:(GBPing *)pinger didFailToSendPingWithSummary:(GBPingSummary *)summary error:(NSError *)error; +- (void)ping:(GBPing *)pinger didTimeoutWithSummary:(GBPingSummary *)summary; +- (void)ping:(GBPing *)pinger didReceiveReplyWithSummary:(GBPingSummary *)summary; +- (void)ping:(GBPing *)pinger didReceiveUnexpectedReplyWithSummary:(GBPingSummary *)summary; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-ping/ios/GBPing/GBPing.m b/native-modules/react-native-ping/ios/GBPing/GBPing.m new file mode 100755 index 00000000..8d1a7d2b --- /dev/null +++ b/native-modules/react-native-ping/ios/GBPing/GBPing.m @@ -0,0 +1,944 @@ +// +// GBPing.m +// + +#import "GBPing.h" +#import "LHDefinition.h" + +#if TARGET_OS_EMBEDDED || TARGET_IPHONE_SIMULATOR + #import +#else + #import +#endif + +#import "ICMPHeader.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +static NSTimeInterval const kPendingPingsCleanupGrace = 1.0; + +static NSUInteger const kDefaultPayloadSize = 56; +static NSUInteger const kDefaultTTL = 49; +static NSTimeInterval const kDefaultPingPeriod = 1.0; +static NSTimeInterval const kDefaultTimeout = 2.0; + +@interface GBPing () + +@property (assign, atomic) int socket; +@property (strong, nonatomic) NSData *hostAddress; +@property (strong, nonatomic) NSString *hostAddressString; +@property (assign, nonatomic) uint16_t identifier; + +@property (assign, atomic, readwrite) BOOL isPinging; +@property (assign, atomic, readwrite) BOOL isReady; +@property (assign, nonatomic) NSUInteger nextSequenceNumber; +@property (strong, atomic) NSMutableDictionary *pendingPings; +@property (strong, nonatomic) NSMutableDictionary *timeoutTimers; + +@property (strong, nonatomic) dispatch_queue_t setupQueue; + +@property (assign, atomic) BOOL isStopped; + +@property (copy, atomic) void (^ successBlock)(GBPingSummary *summary); +@property (copy, atomic) void (^ failBlock)(NSError *summary); + +@end + +@implementation GBPing +{ + NSUInteger _payloadSize; + NSUInteger _ttl; + NSTimeInterval _timeout; + NSTimeInterval _pingPeriod; +} + +#pragma mark - custom acc + +- (void)setTimeout:(NSTimeInterval)timeout +{ + @synchronized(self) { + if (self.isPinging) { + if (self.debug) { + NSLog(@"GBPing: can't set timeout while pinger is running."); + } + } else { + _timeout = timeout; + } + } +} + +- (NSTimeInterval)timeout +{ + @synchronized(self) { + if (!_timeout) { + return kDefaultTimeout; + } else { + return _timeout; + } + } +} + +- (void)setTtl:(NSUInteger)ttl +{ + @synchronized(self) { + if (self.isPinging) { + if (self.debug) { + NSLog(@"GBPing: can't set ttl while pinger is running."); + } + } else { + _ttl = ttl; + } + } +} + +- (NSUInteger)ttl +{ + @synchronized(self) { + if (!_ttl) { + return kDefaultTTL; + } else { + return _ttl; + } + } +} + +- (void)setPayloadSize:(NSUInteger)payloadSize +{ + @synchronized(self) { + if (self.isPinging) { + if (self.debug) { + NSLog(@"GBPing: can't set payload size while pinger is running."); + } + } else { + _payloadSize = payloadSize; + } + } +} + +- (NSUInteger)payloadSize +{ + @synchronized(self) { + if (!_payloadSize) { + return kDefaultPayloadSize; + } else { + return _payloadSize; + } + } +} + +- (void)setPingPeriod:(NSTimeInterval)pingPeriod +{ + @synchronized(self) { + if (self.isPinging) { + if (self.debug) { + NSLog(@"GBPing: can't set pingPeriod while pinger is running."); + } + } else { + _pingPeriod = pingPeriod; + } + } +} + +- (NSTimeInterval)pingPeriod +{ + @synchronized(self) { + if (!_pingPeriod) { + return (NSTimeInterval)kDefaultPingPeriod; + } else { + return _pingPeriod; + } + } +} + +#pragma mark - core pinging methods + +- (void)setupWithBlock:(StartupCallback)callback +{ + //error out of its already setup + if (self.isReady) { + if (self.debug) { + NSLog(@"GBPing: Can't setup, already setup."); + } + + //notify about error and return + dispatch_async(dispatch_get_main_queue(), ^{ + DEFINE_NSError(runningError, PingUtil_Message_PreviousPingIsStillRunning) + callback(NO, runningError); + }); + return; + } + + //error out if no host is set + if (!self.host) { + if (self.debug) { + NSLog(@"GBPing: set host before attempting to start."); + } + + //notify about error and return + dispatch_async(dispatch_get_main_queue(), ^{ + DEFINE_NSError(hostError, PingUtil_Message_HostErrorNotSetHost) + callback(NO, hostError); + }); + return; + } + + //set up data structs + self.nextSequenceNumber = 0; + self.pendingPings = [[NSMutableDictionary alloc] init]; + self.timeoutTimers = [[NSMutableDictionary alloc] init]; + + dispatch_async(self.setupQueue, ^{ + CFStreamError streamError; + BOOL success; + + CFHostRef hostRef = CFHostCreateWithName(NULL, (__bridge CFStringRef)self.host); + + /* + * CFHostCreateWithName will return a null result in certain cases. + * CFHostStartInfoResolution will return YES if _hostRef is null. + */ + if (hostRef) { + success = CFHostStartInfoResolution(hostRef, kCFHostAddresses, &streamError); + } else { + success = NO; + } + + if (!success) { + //construct an error + NSDictionary *userInfo; + NSError *error; + + if (hostRef && streamError.domain == kCFStreamErrorDomainNetDB) { + userInfo = [NSDictionary dictionaryWithObjectsAndKeys: + [NSNumber numberWithInteger:streamError.error], kCFGetAddrInfoFailureKey, + nil + ]; + } else { + userInfo = nil; + } + error = [NSError errorWithDomain:(NSString *)kCFErrorDomainCFNetwork code:kCFHostErrorUnknown userInfo:userInfo]; + + //clean up so far + [self stop]; + + //notify about error and return + dispatch_async(dispatch_get_main_queue(), ^{ + DEFINE_NSError(hostUnknowError, PingUtil_Message_HostErrorUnknown) + callback(NO, hostUnknowError); + }); + + //just incase + if (hostRef) { + CFRelease(hostRef); + } + return; + } + + //get the first IPv4 or IPv6 address + Boolean resolved; + NSArray *addresses = (__bridge NSArray *)CFHostGetAddressing(hostRef, &resolved); + if (resolved && (addresses != nil)) { + resolved = false; + for (NSData *address in addresses) { + const struct sockaddr *anAddrPtr = (const struct sockaddr *)[address bytes]; + + if ([address length] >= sizeof(struct sockaddr) && + (anAddrPtr->sa_family == AF_INET || anAddrPtr->sa_family == AF_INET6)) { + resolved = true; + self.hostAddress = address; + struct sockaddr_in *sin = (struct sockaddr_in *)anAddrPtr; + char str[INET6_ADDRSTRLEN]; + inet_ntop(anAddrPtr->sa_family, &(sin->sin_addr), str, INET6_ADDRSTRLEN); + self.hostAddressString = [[NSString alloc] initWithUTF8String:str]; + break; + } + } + } + + //we can stop host resolution now + if (hostRef) { + CFRelease(hostRef); + } + + //if an error occurred during resolution + if (!resolved) { + //stop + [self stop]; + + //notify about error and return + dispatch_async(dispatch_get_main_queue(), ^{ + DEFINE_NSError(hostError, PingUtil_Message_HostErrorHostNotFound) + callback(NO, hostError); + }); + return; + } + + //set up socket + int err = 0; + switch (self.hostAddressFamily) { + case AF_INET: { + self.socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP); + if (self.socket < 0) { + err = errno; + } + } break; + case AF_INET6: { + self.socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_ICMPV6); + if (self.socket < 0) { + err = errno; + } + } break; + default: { + err = EPROTONOSUPPORT; + } break; + } + + //couldnt setup socket + if (err) { + //clean up so far + [self stop]; + + //notify about error and close + dispatch_async(dispatch_get_main_queue(), ^{ + DEFINE_NSError(unknownError, PingUtil_Message_Unknown) + callback(NO, unknownError); + }); + return; + } + + //set ttl on the socket + if (self.ttl) { + u_char ttlForSockOpt = (u_char)self.ttl; + setsockopt(self.socket, IPPROTO_IP, IP_TTL, &ttlForSockOpt, sizeof(NSUInteger)); + } + + //we are ready now + self.isReady = YES; + + //notify that we are ready + dispatch_async(dispatch_get_main_queue(), ^{ + callback(YES, nil); + }); + }); + + self.isStopped = NO; +} + +- (void)startPingingWithBlock:(void (^)(GBPingSummary *))successBlock fail:(void (^)(NSError *))failBlock +{ + if (self.isReady && !self.isPinging) { + self.successBlock = [successBlock copy]; + self.failBlock = [failBlock copy]; + //go into infinite listenloop on a new thread (listenThread) + NSThread *listenThread = [[NSThread alloc] initWithTarget:self selector:@selector(listenLoop) object:nil]; + listenThread.name = @"listenThread"; + + //set up loop that sends packets on a new thread (sendThread) + NSThread *sendThread = [[NSThread alloc] initWithTarget:self selector:@selector(sendLoop) object:nil]; + sendThread.name = @"sendThread"; + + //we're pinging now + self.isPinging = YES; + [listenThread start]; + [sendThread start]; + } +} + +- (void)listenLoop +{ + @autoreleasepool { + while (self.isPinging) + [self listenOnce]; + } +} + +- (void)listenOnce +{ + int err; + struct sockaddr_storage addr; + socklen_t addrLen; + ssize_t bytesRead; + void *buffer; + enum { kBufferSize = 65535 }; + + buffer = malloc(kBufferSize); + assert(buffer); + + //set socket timeout + struct timeval tv; + tv.tv_sec = self.timeout / 1000; + tv.tv_usec = 0; + if (setsockopt(self.socket, SOL_SOCKET, SO_RCVTIMEO,&tv,sizeof(tv)) < 0) { + NSLog(@"Set Timeput Error"); + } + + //read the data. + addrLen = sizeof(addr); + bytesRead = recvfrom(self.socket, buffer, kBufferSize, 0, (struct sockaddr *)&addr, &addrLen); + err = 0; + if (bytesRead < 0) { + err = errno; + } + + //process the data we read. + if (bytesRead > 0) { + char hoststr[INET6_ADDRSTRLEN]; + struct sockaddr_in *sin = (struct sockaddr_in *)&addr; + inet_ntop(sin->sin_family, &(sin->sin_addr), hoststr, INET6_ADDRSTRLEN); + NSString *host = [[NSString alloc] initWithUTF8String:hoststr]; + + if ([host isEqualToString:self.hostAddressString]) { // only make sense where received packet comes from expected source + NSDate *receiveDate = [NSDate date]; + NSMutableData *packet; + + packet = [NSMutableData dataWithBytes:buffer length:(NSUInteger)bytesRead]; + assert(packet); + + //complete the ping summary + const struct ICMPHeader *headerPointer; + + if (sin->sin_family == AF_INET) { + headerPointer = [[self class] icmp4InPacket:packet]; + } else { + headerPointer = (const struct ICMPHeader *)[packet bytes]; + } + + NSUInteger seqNo = (NSUInteger)OSSwapBigToHostInt16(headerPointer->sequenceNumber); + NSNumber *key = @(seqNo); + GBPingSummary *pingSummary = [(GBPingSummary *)self.pendingPings[key] copy]; + + if (pingSummary) { + if ([self isValidPingResponsePacket:packet]) { + //override the source address (we might have sent to google.com and 172.123.213.192 replied) + pingSummary.receiveDate = receiveDate; + // IP can't be read from header for ICMPv6 + if (sin->sin_family == AF_INET) { + pingSummary.host = [[self class] sourceAddressInPacket:packet]; + + //set ttl from response (different servers may respond with different ttls) + const struct IPHeader *ipPtr; + if ([packet length] >= sizeof(IPHeader)) { + ipPtr = (const IPHeader *)[packet bytes]; + pingSummary.ttl = ipPtr->timeToLive; + } + } + + pingSummary.status = GBPingStatusSuccess; + + //invalidate the timeouttimer + NSTimer *timer = self.timeoutTimers[key]; + [timer invalidate]; + [self.timeoutTimers removeObjectForKey:key]; + + if (self.successBlock) { + self.successBlock([pingSummary copy]); + } + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didReceiveReplyWithSummary:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + //notify delegate + [self.delegate ping:self didReceiveReplyWithSummary:[pingSummary copy]]; + }); + } + } else { + pingSummary.status = GBPingStatusFail; + + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didReceiveUnexpectedReplyWithSummary:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ping:self didReceiveUnexpectedReplyWithSummary:[pingSummary copy]]; + }); + } + } + } + } + } else { + //we failed to read the data, so shut everything down. + if (err == 0) { + err = EPIPE; + } + + @synchronized(self) { + if (!self.isStopped) { + if (self.failBlock) { + DEFINE_NSError(unknownError, PingUtil_Message_Unknown) + _failBlock(unknownError); + } + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didFailWithError:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ping:self didFailWithError:[NSError errorWithDomain:NSPOSIXErrorDomain code:err userInfo:nil]]; + }); + } + } + } + + //stop the whole thing + [self stop]; + } + + free(buffer); +} + +- (void)sendLoop +{ + @autoreleasepool { + while (self.isPinging) { + [self sendPing]; + + NSTimeInterval runUntil = CFAbsoluteTimeGetCurrent() + self.pingPeriod; + NSTimeInterval time = 0; + while (runUntil > time) { + NSDate *runUntilDate = [NSDate dateWithTimeIntervalSinceReferenceDate:runUntil]; + [[NSRunLoop currentRunLoop] runUntilDate:runUntilDate]; + + time = CFAbsoluteTimeGetCurrent(); + } + } + } +} + +- (void)sendPing +{ + if (self.isPinging) { + int err; + NSData *packet; + ssize_t bytesSent; + + // Construct the ping packet. + NSData *payload = [self generateDataWithLength:(self.payloadSize)]; + + switch (self.hostAddressFamily) { + case AF_INET: { + packet = [self pingPacketWithType:kICMPv4TypeEchoRequest payload:payload requiresChecksum:YES]; + } break; + case AF_INET6: { + packet = [self pingPacketWithType:kICMPv6TypeEchoRequest payload:payload requiresChecksum:NO]; + } break; + default: { + assert(NO); + } break; + } + + // this is our ping summary + GBPingSummary *newPingSummary = [GBPingSummary new]; + + // Send the packet. + if (self.socket == 0) { + bytesSent = -1; + err = EBADF; + } else { + //record the send date + NSDate *sendDate = [NSDate date]; + + //construct ping summary, as much as it can + newPingSummary.sequenceNumber = self.nextSequenceNumber; + newPingSummary.host = self.host; + newPingSummary.sendDate = sendDate; + newPingSummary.ttl = self.ttl; + newPingSummary.payloadSize = self.payloadSize; + newPingSummary.status = GBPingStatusPending; + + //add it to pending pings + NSNumber *key = @(self.nextSequenceNumber); + self.pendingPings[key] = newPingSummary; + + //increment sequence number + self.nextSequenceNumber += 1; + + //we create a copy, this one will be passed out to other threads + GBPingSummary *pingSummaryCopy = [newPingSummary copy]; + + //we need to clean up our list of pending pings, and we do that after the timeout has elapsed (+ some grace period) + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((self.timeout + kPendingPingsCleanupGrace) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + //remove the ping from the pending list + [self.pendingPings removeObjectForKey:key]; + }); + + //add a timeout timer + //add a timeout timer + NSTimer *timeoutTimer = [NSTimer scheduledTimerWithTimeInterval:self.timeout + target:[NSBlockOperation blockOperationWithBlock:^{ + newPingSummary.status = GBPingStatusFail; + + //notify about the failure + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didTimeoutWithSummary:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ping:self didTimeoutWithSummary:pingSummaryCopy]; + }); + } + + //remove the timer itself from the timers list + //lm make sure that the timer list doesnt grow and these removals actually work... try logging the count of the timeoutTimers when stopping the pinger + [self.timeoutTimers removeObjectForKey:key]; + }] + selector:@selector(main) + userInfo:nil + repeats:NO]; + [[NSRunLoop mainRunLoop] addTimer:timeoutTimer forMode:NSRunLoopCommonModes]; + + //keep a local ref to it + if (self.timeoutTimers) { + self.timeoutTimers[key] = timeoutTimer; + } + + //notify delegate about this + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didSendPingWithSummary:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ping:self didSendPingWithSummary:pingSummaryCopy]; + }); + } + + bytesSent = sendto( + self.socket, + [packet bytes], + [packet length], + 0, + (struct sockaddr *)[self.hostAddress bytes], + (socklen_t)[self.hostAddress length] + ); + err = 0; + if (bytesSent < 0) { + err = errno; + } + } + + // This is after the sending + + //successfully sent + if ((bytesSent > 0) && (((NSUInteger)bytesSent) == [packet length])) { + //noop, we already notified delegate about sending of the ping + } + //failed to send + else { + //complete the error + if (err == 0) { + err = ENOBUFS; // This is not a hugely descriptor error, alas. + } + + //little log + if (self.debug) { + NSLog(@"GBPing: failed to send packet with error code: %d", err); + } + + //change status + newPingSummary.status = GBPingStatusFail; + + GBPingSummary *pingSummaryCopyAfterFailure = [newPingSummary copy]; + + if (self.failBlock) { + DEFINE_NSError(unknownError, PingUtil_Message_Unknown) + self.failBlock(unknownError); + } + //notify delegate + if (self.delegate && [self.delegate respondsToSelector:@selector(ping:didFailToSendPingWithSummary:error:)]) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self.delegate ping:self didFailToSendPingWithSummary:pingSummaryCopyAfterFailure error:[NSError errorWithDomain:NSPOSIXErrorDomain code:err userInfo:nil]]; + }); + } + } + } +} + +- (void)stop +{ + @synchronized(self) { + if (!self.isStopped) { + self.isPinging = NO; + + self.isReady = NO; + + //destroy listenThread by closing socket (listenThread) + if (self.socket) { + close(self.socket); + self.socket = 0; + } + + //destroy host + self.hostAddress = nil; + + //clean up pendingpings + [self.pendingPings removeAllObjects]; + self.pendingPings = nil; + for (NSNumber *key in [self.timeoutTimers copy]) { + NSTimer *timer = self.timeoutTimers[key]; + [timer invalidate]; + } + + //clean up timeouttimers + [self.timeoutTimers removeAllObjects]; + self.timeoutTimers = nil; + + //reset seq number + self.nextSequenceNumber = 0; + + self.isStopped = YES; + } + } +} + +#pragma mark - util + +static uint16_t in_cksum(const void *buffer, size_t bufferLen) +// This is the standard BSD checksum code, modified to use modern types. +{ + size_t bytesLeft; + int32_t sum; + const uint16_t *cursor; + union { + uint16_t us; + uint8_t uc[2]; + } last; + uint16_t answer; + + bytesLeft = bufferLen; + sum = 0; + cursor = buffer; + + /* + * Our algorithm is simple, using a 32 bit accumulator (sum), we add + * sequential 16 bit words to it, and at the end, fold back all the + * carry bits from the top 16 bits into the lower 16 bits. + */ + while (bytesLeft > 1) { + sum += *cursor; + cursor += 1; + bytesLeft -= 2; + } + + /* mop up an odd byte, if necessary */ + if (bytesLeft == 1) { + last.uc[0] = *(const uint8_t *)cursor; + last.uc[1] = 0; + sum += last.us; + } + + /* add back carry outs from top 16 bits to low 16 bits */ + sum = (sum >> 16) + (sum & 0xffff); /* add hi 16 to low 16 */ + sum += (sum >> 16); /* add carry */ + answer = (uint16_t) ~sum; /* truncate to 16 bits */ + + return answer; +} + ++ (NSString *)sourceAddressInPacket:(NSData *)packet +{ + // Returns the source address of the IP packet + + const struct IPHeader *ipPtr; + const uint8_t *sourceAddress; + + if ([packet length] >= sizeof(IPHeader)) { + ipPtr = (const IPHeader *)[packet bytes]; + + sourceAddress = ipPtr->sourceAddress;//dont need to swap byte order those cuz theyre the smallest atomic unit (1 byte) + NSString *ipString = [NSString stringWithFormat:@"%d.%d.%d.%d", sourceAddress[0], sourceAddress[1], sourceAddress[2], sourceAddress[3]]; + + return ipString; + } else return nil; +} + ++ (NSUInteger)icmp4HeaderOffsetInPacket:(NSData *)packet +// Returns the offset of the ICMPHeader within an IP packet. +{ + NSUInteger result; + const struct IPHeader *ipPtr; + size_t ipHeaderLength; + + result = NSNotFound; + if ([packet length] >= (sizeof(IPHeader) + sizeof(ICMPHeader))) { + ipPtr = (const IPHeader *)[packet bytes]; + assert((ipPtr->versionAndHeaderLength & 0xF0) == 0x40); // IPv4 + assert(ipPtr->protocol == 1); // ICMP + ipHeaderLength = (ipPtr->versionAndHeaderLength & 0x0F) * sizeof(uint32_t); + if ([packet length] >= (ipHeaderLength + sizeof(ICMPHeader))) { + result = ipHeaderLength; + } + } + return result; +} + ++ (const struct ICMPHeader *)icmp4InPacket:(NSData *)packet +// See comment in header. +{ + const struct ICMPHeader *result; + NSUInteger icmpHeaderOffset; + + result = nil; + icmpHeaderOffset = [self icmp4HeaderOffsetInPacket:packet]; + if (icmpHeaderOffset != NSNotFound) { + result = (const struct ICMPHeader *)(((const uint8_t *)[packet bytes]) + icmpHeaderOffset); + } + return result; +} + +- (BOOL)isValidPingResponsePacket:(NSMutableData *)packet +{ + BOOL result; + + switch (self.hostAddressFamily) { + case AF_INET: { + result = [self isValidPing4ResponsePacket:packet]; + } break; + case AF_INET6: { + result = [self isValidPing6ResponsePacket:packet]; + } break; + default: { + assert(NO); + result = NO; + } break; + } + return result; +} + +- (BOOL)isValidPing4ResponsePacket:(NSMutableData *)packet +// Returns true if the packet looks like a valid ping response packet destined +// for us. +{ + BOOL result; + NSUInteger icmpHeaderOffset; + ICMPHeader *icmpPtr; + uint16_t receivedChecksum; + uint16_t calculatedChecksum; + + result = NO; + + icmpHeaderOffset = [[self class] icmp4HeaderOffsetInPacket:packet]; + if (icmpHeaderOffset != NSNotFound) { + icmpPtr = (struct ICMPHeader *)(((uint8_t *)[packet mutableBytes]) + icmpHeaderOffset); + + receivedChecksum = icmpPtr->checksum; + icmpPtr->checksum = 0; + calculatedChecksum = in_cksum(icmpPtr, [packet length] - icmpHeaderOffset); + icmpPtr->checksum = receivedChecksum; + + if (receivedChecksum == calculatedChecksum) { + if ( (icmpPtr->type == kICMPv4TypeEchoReply) && (icmpPtr->code == 0) ) { + if (OSSwapBigToHostInt16(icmpPtr->identifier) == self.identifier) { + if (OSSwapBigToHostInt16(icmpPtr->sequenceNumber) < self.nextSequenceNumber) { + result = YES; + } + } + } + } + } + + // NSLog(@"valid: %@, type: %d", _b(result), icmpPtr->type); + + return result; +} + +- (BOOL)isValidPing6ResponsePacket:(NSMutableData *)packet +// Returns true if the IPv6 packet looks like a valid ping response packet destined +// for us. +{ + BOOL result; + const ICMPHeader *icmpPtr; + + result = NO; + + if (packet.length >= sizeof(*icmpPtr)) { + icmpPtr = packet.bytes; + + if ( (icmpPtr->type == kICMPv6TypeEchoReply) && (icmpPtr->code == 0) ) { + if (OSSwapBigToHostInt16(icmpPtr->identifier) == self.identifier) { + if (OSSwapBigToHostInt16(icmpPtr->sequenceNumber) < self.nextSequenceNumber) { + result = YES; + } + } + } + } + + return result; +} + +- (NSData *)generateDataWithLength:(NSUInteger)length +{ + //create a buffer full of 7's of specified length + char tempBuffer[length]; + memset(tempBuffer, 7, length); + + return [[NSData alloc] initWithBytes:tempBuffer length:length]; +} + +- (void)_invokeTimeoutCallback:(NSTimer *)timer +{ + dispatch_block_t callback = timer.userInfo; + if (callback) { + callback(); + } +} + +- (NSData *)pingPacketWithType:(uint8_t)type payload:(NSData *)payload requiresChecksum:(BOOL)requiresChecksum +{ + NSMutableData *packet; + ICMPHeader *icmpPtr; + + packet = [NSMutableData dataWithLength:sizeof(*icmpPtr) + payload.length]; + assert(packet != nil); + + icmpPtr = packet.mutableBytes; + icmpPtr->type = type; + icmpPtr->code = 0; + icmpPtr->checksum = 0; + icmpPtr->identifier = OSSwapHostToBigInt16(self.identifier); + icmpPtr->sequenceNumber = OSSwapHostToBigInt16(self.nextSequenceNumber); + memcpy(&icmpPtr[1], [payload bytes], [payload length]); + + if (requiresChecksum) { + // The IP checksum routine returns a 16-bit number that's already in correct byte order + // (due to wacky 1's complement maths), so we just put it into the packet as a 16-bit unit. + + icmpPtr->checksum = in_cksum(packet.bytes, packet.length); + } + + return packet; +} + +- (sa_family_t)hostAddressFamily +{ + sa_family_t result; + + result = AF_UNSPEC; + if ( (self.hostAddress != nil) && (self.hostAddress.length >= sizeof(struct sockaddr)) ) { + result = ((const struct sockaddr *)self.hostAddress.bytes)->sa_family; + } + return result; +} + +#pragma mark - memory + +- (id)init +{ + if (self = [super init]) { + self.setupQueue = dispatch_queue_create("GBPing setup queue", 0); + self.isStopped = YES; + self.identifier = arc4random(); + } + + return self; +} + +- (void)dealloc +{ + self.delegate = nil; + self.host = nil; + self.timeoutTimers = nil; + self.pendingPings = nil; + self.hostAddress = nil; + + //clean up socket to be sure + if (self.socket) { + close(self.socket); + self.socket = 0; + } +} + +@end diff --git a/native-modules/react-native-ping/ios/GBPing/GBPingSummary.h b/native-modules/react-native-ping/ios/GBPing/GBPingSummary.h new file mode 100755 index 00000000..40c1d8f3 --- /dev/null +++ b/native-modules/react-native-ping/ios/GBPing/GBPingSummary.h @@ -0,0 +1,24 @@ +// +// GBPingSummary.h +// + +#import + +@interface GBPingSummary : NSObject + +typedef enum { + GBPingStatusPending, + GBPingStatusSuccess, + GBPingStatusFail, +} GBPingStatus; + +@property (assign, nonatomic) NSUInteger sequenceNumber; +@property (assign, nonatomic) NSUInteger payloadSize; +@property (assign, nonatomic) NSUInteger ttl; +@property (strong, nonatomic, nullable) NSString *host; +@property (strong, nonatomic, nullable) NSDate *sendDate; +@property (strong, nonatomic, nullable) NSDate *receiveDate; +@property (assign, nonatomic) NSTimeInterval rtt; +@property (assign, nonatomic) GBPingStatus status; + +@end diff --git a/native-modules/react-native-ping/ios/GBPing/GBPingSummary.m b/native-modules/react-native-ping/ios/GBPing/GBPingSummary.m new file mode 100755 index 00000000..27d06f67 --- /dev/null +++ b/native-modules/react-native-ping/ios/GBPing/GBPingSummary.m @@ -0,0 +1,67 @@ +// +// GBPingSummary.m +// + +#import "GBPingSummary.h" + +@implementation GBPingSummary + +#pragma mark - custom acc + +- (void)setHost:(NSString *)host +{ + _host = host; +} + +- (NSTimeInterval)rtt +{ + if (self.sendDate) { + return [self.receiveDate timeIntervalSinceDate:self.sendDate]; + } else { + return 0; + } +} + +#pragma mark - copying + +- (id)copyWithZone:(NSZone *)zone +{ + GBPingSummary *copy = [[[self class] allocWithZone:zone] init]; + + copy.sequenceNumber = self.sequenceNumber; + copy.payloadSize = self.payloadSize; + copy.ttl = self.ttl; + copy.host = [self.host copy]; + copy.sendDate = [self.sendDate copy]; + copy.receiveDate = [self.receiveDate copy]; + copy.status = self.status; + + return copy; +} + +#pragma mark - memory + +- (id)init +{ + if (self = [super init]) { + self.status = GBPingStatusPending; + } + + return self; +} + +- (void)dealloc +{ + self.host = nil; + self.sendDate = nil; + self.receiveDate = nil; +} + +#pragma mark - description + +- (NSString *)description +{ + return [NSString stringWithFormat:@"host: %@, seq: %lu, status: %d, ttl: %lu, payloadSize: %lu, sendDate: %@, receiveDate: %@, rtt: %f", self.host, (unsigned long)self.sequenceNumber, self.status, (unsigned long)self.ttl, (unsigned long)self.payloadSize, self.sendDate, self.receiveDate, self.rtt]; +} + +@end diff --git a/native-modules/react-native-ping/ios/GBPing/ICMPHeader.h b/native-modules/react-native-ping/ios/GBPing/ICMPHeader.h new file mode 100755 index 00000000..91f1e7bf --- /dev/null +++ b/native-modules/react-native-ping/ios/GBPing/ICMPHeader.h @@ -0,0 +1,79 @@ +// +// ICMPHeader.h +// GBPing +// +// Created by Luka Mirosevic on 15/11/2012. +// Copyright (c) 2012 Goonbee. All rights reserved. +// + +#ifndef GBPing_ICMPHeader_h +#define GBPing_ICMPHeader_h + +#include + +#pragma mark - IP and ICMP On-The-Wire Format + +// The following declarations specify the structure of ping packets on the wire. + +// IP header structure: + +struct IPHeader { + uint8_t versionAndHeaderLength; + uint8_t differentiatedServices; + uint16_t totalLength; + uint16_t identification; + uint16_t flagsAndFragmentOffset; + uint8_t timeToLive; + uint8_t protocol; + uint16_t headerChecksum; + uint8_t sourceAddress[4]; + uint8_t destinationAddress[4]; + // options... + // data... +}; +typedef struct IPHeader IPHeader; + +__Check_Compile_Time(sizeof(IPHeader) == 20); +__Check_Compile_Time(offsetof(IPHeader, versionAndHeaderLength) == 0); +__Check_Compile_Time(offsetof(IPHeader, differentiatedServices) == 1); +__Check_Compile_Time(offsetof(IPHeader, totalLength) == 2); +__Check_Compile_Time(offsetof(IPHeader, identification) == 4); +__Check_Compile_Time(offsetof(IPHeader, flagsAndFragmentOffset) == 6); +__Check_Compile_Time(offsetof(IPHeader, timeToLive) == 8); +__Check_Compile_Time(offsetof(IPHeader, protocol) == 9); +__Check_Compile_Time(offsetof(IPHeader, headerChecksum) == 10); +__Check_Compile_Time(offsetof(IPHeader, sourceAddress) == 12); +__Check_Compile_Time(offsetof(IPHeader, destinationAddress) == 16); + +// ICMP type and code combinations: + +enum { + kICMPv4TypeEchoRequest = 8, + kICMPv4TypeEchoReply = 0 +}; + +enum { + kICMPv6TypeEchoRequest = 128, + kICMPv6TypeEchoReply = 129 +}; + +// ICMP header structure: + +struct ICMPHeader { + uint8_t type; + uint8_t code; + uint16_t checksum; + uint16_t identifier; + uint16_t sequenceNumber; + // data... +}; +typedef struct ICMPHeader ICMPHeader; + +__Check_Compile_Time(sizeof(ICMPHeader) == 8); +__Check_Compile_Time(offsetof(ICMPHeader, type) == 0); +__Check_Compile_Time(offsetof(ICMPHeader, code) == 1); +__Check_Compile_Time(offsetof(ICMPHeader, checksum) == 2); +__Check_Compile_Time(offsetof(ICMPHeader, identifier) == 4); +__Check_Compile_Time(offsetof(ICMPHeader, sequenceNumber) == 6); + +#endif diff --git a/native-modules/react-native-ping/ios/LHNetwork/LHDefinition.h b/native-modules/react-native-ping/ios/LHNetwork/LHDefinition.h new file mode 100644 index 00000000..5795e8ec --- /dev/null +++ b/native-modules/react-native-ping/ios/LHNetwork/LHDefinition.h @@ -0,0 +1,24 @@ +// +// LHDefinition.h +// RNReactNativePing +// +// Created by Jerry-Luo on 2019/3/29. +// Copyright © 2019 Pomato. All rights reserved. +// + +#ifndef LHDefinition_h +#define LHDefinition_h + +#define DEFINE_NSError(errorName,description) NSError *errorName = [NSError errorWithDomain:@#description code:description userInfo:@{@"code":@(description),@"message":@#description}]; + +typedef NS_ENUM (NSUInteger, PingErrorCode) { + PingUtil_Message_Timeout, + PingUtil_Message_PreviousPingIsStillRunning, + PingUtil_Message_HostErrorNotSetHost, + PingUtil_Message_HostErrorUnknown, + PingUtil_Message_HostErrorHostNotFound, + PingUtil_Message_Unknown, +}; + + +#endif /* LHDefinition_h */ diff --git a/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.h b/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.h new file mode 100755 index 00000000..25d3d180 --- /dev/null +++ b/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.h @@ -0,0 +1,34 @@ +// +// LHNetwork.h +// + +#import +#import + +@interface LHNetwork : NSObject + +@property (nonatomic, copy, readonly) NSString *receivedNetworkSpeed; + +@property (nonatomic, copy, readonly) NSString *sendNetworkSpeed; + +@property (nonatomic, copy, readonly) NSString *receivedNetworkTotal; + +@property (nonatomic, copy, readonly) NSString *sendNetworkTotal; ++ (instancetype)shareNetworkSpeed; + +- (void)startMonitoringNetwork; + +- (void)stopMonitoringNetwork; + +- (void)checkNetworkflow; +@end + +/** + * @{@"received":@"100kB/s"} + */ +FOUNDATION_EXTERN NSString *const kNetworkReceivedSpeedNotification; + +/** + * @{@"send":@"100kB/s"} + */ +FOUNDATION_EXTERN NSString *const kNetworkSendSpeedNotification; diff --git a/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.m b/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.m new file mode 100755 index 00000000..31d06206 --- /dev/null +++ b/native-modules/react-native-ping/ios/LHNetwork/LHNetwork.m @@ -0,0 +1,223 @@ +// +// LHNetwork.m +// +#import "LHNetwork.h" +#include +#include +#include +#include + +/** + * @{@"received":@"100kB/s"} + */ +NSString *const kNetworkReceivedSpeedNotification = @"kNetworkReceivedSpeedNotification"; + +/** + * @{@"send":@"100kB/s"} + */ +NSString *const kNetworkSendSpeedNotification = @"kNetworkSendSpeedNotification"; + +@interface LHNetwork () +{ + uint32_t _iBytes; + uint32_t _oBytes; + uint32_t _allFlow; + uint32_t _wifiIBytes; + uint32_t _wifiOBytes; + uint32_t _wifiFlow; + uint32_t _wwanIBytes; + uint32_t _wwanOBytes; + uint32_t _wwanFlow; +} + +@property (nonatomic, copy) NSString *receivedNetworkSpeed; + +@property (nonatomic, copy) NSString *sendNetworkSpeed; + +@property (nonatomic, copy) NSString *receivedNetworkTotal; + +@property (nonatomic, copy) NSString *sendNetworkTotal; + +@property (nonatomic, strong) NSTimer *timer; + +@end + +@implementation LHNetwork + +static LHNetwork *instance = nil; + ++ (instancetype)shareNetworkSpeed +{ + if (instance == nil) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[self alloc] init]; + }); + } + return instance; +} + ++ (instancetype)allocWithZone:(struct _NSZone *)zone +{ + if (instance == nil) { + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [super allocWithZone:zone]; + }); + } + return instance; +} + +- (instancetype)init +{ + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [super init]; + _iBytes = _oBytes = _allFlow = _wifiIBytes = _wifiOBytes = _wifiFlow = _wwanIBytes = _wwanOBytes = _wwanFlow = 0; + }); + return instance; +} + +- (void)startMonitoringNetwork +{ + if (_timer) [self stopMonitoringNetwork]; + + [[UIApplication sharedApplication] beginBackgroundTaskWithExpirationHandler:nil]; + _timer = [NSTimer scheduledTimerWithTimeInterval:1 target:self selector:@selector(netSpeedNotification) userInfo:nil repeats:YES]; + [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes]; +} + +- (void)stopMonitoringNetwork +{ + if ([_timer isValid]) { + [_timer invalidate]; + } +} + +- (void)netSpeedNotification +{ + [self checkNetworkflow]; +} + +- (NSString *)bytesToAvaiUnit:(uint32_t)bytes +{ + if (bytes < 1024) { // B + return [NSString stringWithFormat:@"%dB", bytes]; + } else if (bytes >= 1024 && bytes < 1024 * 1024) { // KB + return [NSString stringWithFormat:@"%.1fKB", (double)bytes / 1024]; + } else if (bytes >= 1024 * 1024 && bytes < 1024 * 1024 * 1024) { // MB + return [NSString stringWithFormat:@"%.1fMB", (double)bytes / (1024 * 1024)]; + } else { // GB + return [NSString stringWithFormat:@"%.1fGB", (double)bytes / (1024 * 1024 * 1024)]; + } +} + +- (void)checkNetworkflow +{ + struct ifaddrs *ifa_list = 0, *ifa; + if (getifaddrs(&ifa_list) == -1) { + return; + } + + uint32_t iBytes = 0; + uint32_t oBytes = 0; + uint32_t allFlow = 0; + uint32_t wifiIBytes = 0; + uint32_t wifiOBytes = 0; + uint32_t wifiFlow = 0; + uint32_t wwanIBytes = 0; + uint32_t wwanOBytes = 0; + uint32_t wwanFlow = 0; + // struct timeval32 time; + + for (ifa = ifa_list; ifa; ifa = ifa->ifa_next) { + if (AF_LINK != ifa->ifa_addr->sa_family) continue; + + if (!(ifa->ifa_flags & IFF_UP) && !(ifa->ifa_flags & IFF_RUNNING)) continue; + + if (ifa->ifa_data == 0) continue; + + // network flow + if (strncmp(ifa->ifa_name, "lo", 2)) { + struct if_data *if_data = (struct if_data *)ifa->ifa_data; + + iBytes += if_data->ifi_ibytes; + oBytes += if_data->ifi_obytes; + allFlow = iBytes + oBytes; + // time = if_data->ifi_lastchange; + } + + //wifi flow + if (!strcmp(ifa->ifa_name, "en0")) { + struct if_data *if_data = (struct if_data *)ifa->ifa_data; + + wifiIBytes += if_data->ifi_ibytes; + wifiOBytes += if_data->ifi_obytes; + wifiFlow = wifiIBytes + wifiOBytes; + } + + //3G and gprs flow + if (!strcmp(ifa->ifa_name, "pdp_ip0")) { + struct if_data *if_data = (struct if_data *)ifa->ifa_data; + + wwanIBytes += if_data->ifi_ibytes; + wwanOBytes += if_data->ifi_obytes; + wwanFlow = wwanIBytes + wwanOBytes; + } + } + freeifaddrs(ifa_list); + + if (_iBytes != 0) { + self.receivedNetworkSpeed = [[self bytesToAvaiUnit:iBytes - _iBytes] stringByAppendingString:@"/s"]; + self.receivedNetworkTotal = [self bytesToAvaiUnit:iBytes]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNetworkReceivedSpeedNotification object:@{ @"received": self.receivedNetworkSpeed }]; + } + + _iBytes = iBytes; + + if (_oBytes != 0) { + self.sendNetworkSpeed = [[self bytesToAvaiUnit:oBytes - _oBytes] stringByAppendingString:@"/s"]; + self.sendNetworkTotal = [self bytesToAvaiUnit:oBytes]; + [[NSNotificationCenter defaultCenter] postNotificationName:kNetworkSendSpeedNotification object:@{ @"send": self.sendNetworkSpeed }]; + } + _oBytes = oBytes; + + // // + // // NSLog(@"sentBytes==%@",sentBytes); + // NSString *networkFlow = [self bytesToAvaiUnit:allFlow]; + // // + // NSLog(@"networkFlow==%@", networkFlow); + // // + // NSString *wifiReceived = [self bytesToAvaiUnit:wifiIBytes]; + + // NSLog(@"wifiReceived==%@", wifiReceived); + + // NSString *wifiSent = [self bytesToAvaiUnit:wifiOBytes]; + + // NSLog(@"wifiSent==%@", wifiSent); + + // NSString *wifiBytes = [self bytesToAvaiUnit:wifiFlow]; + // // + // NSLog(@"wifiBytes==%@", wifiBytes); + // NSString *wwanReceived = [self bytesToAvaiUnit:wwanIBytes]; + + // NSLog(@"wwanReceived==%@", wwanReceived); + // NSString *wwanSent = [self bytesToAvaiUnit:wwanOBytes]; + + // NSLog(@"wwanSent==%@", wwanSent); + // NSString *wwanBytes = [self bytesToAvaiUnit:wwanFlow]; + // // + // NSLog(@"wwanBytes==%@", wwanBytes); + + // NSDateFormatter *formatter = [[NSDateFormatter alloc] init]; + + // [formatter setDateFormat:@"YYYY-MM-dd hh:mm:ss:SSS"]; + + // NSDate *datenow = [NSDate date]; + + // NSString *nowtimeStr = [formatter stringFromDate:datenow]; + + // NSLog(@"time 2 =  %@", nowtimeStr); +} + +@end diff --git a/native-modules/react-native-ping/ios/Ping.h b/native-modules/react-native-ping/ios/Ping.h new file mode 100644 index 00000000..17a590e0 --- /dev/null +++ b/native-modules/react-native-ping/ios/Ping.h @@ -0,0 +1,10 @@ +#import + +@interface Ping : NativePingSpecBase + +- (void)start:(NSString *)ipAddress + option:(JS::NativePing::SpecStartOption &)option + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +@end diff --git a/native-modules/react-native-ping/ios/Ping.mm b/native-modules/react-native-ping/ios/Ping.mm new file mode 100644 index 00000000..4ae41b49 --- /dev/null +++ b/native-modules/react-native-ping/ios/Ping.mm @@ -0,0 +1,91 @@ +#import "Ping.h" +#import "GBPing/GBPing.h" +#import "LHNetwork/LHNetwork.h" +#import "LHNetwork/LHDefinition.h" + +@interface Ping () +@property (nonatomic, strong) dispatch_queue_t queue; +@end + +@implementation Ping + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNReactNativePing"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (dispatch_queue_t)methodQueue +{ + if (!_queue) { + _queue = dispatch_queue_create("com.onekey.RNPing", DISPATCH_QUEUE_SERIAL); + } + return _queue; +} + +- (void)start:(NSString *)ipAddress + option:(JS::NativePing::SpecStartOption &)option + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + __block GBPing *ping = [[GBPing alloc] init]; + ping.timeout = 1.0; + ping.payloadSize = 56; + ping.pingPeriod = 0.9; + ping.host = ipAddress; + + unsigned long long timeout = 1000; + if (option.timeout().has_value()) { + timeout = (unsigned long long)option.timeout().value(); + ping.timeout = (NSTimeInterval)timeout; + } + + if (option.payloadSize().has_value()) { + unsigned long long payloadSize = (unsigned long long)option.payloadSize().value(); + ping.payloadSize = payloadSize; + } + + dispatch_queue_t capturedQueue = _queue; + + [ping setupWithBlock:^(BOOL success, NSError *_Nullable err) { + if (!success) { + reject(@(err.code).stringValue, err.domain, err); + return; + } + [ping startPingingWithBlock:^(GBPingSummary *summary) { + if (!ping) { + return; + } + resolve(@(@(summary.rtt * 1000).intValue)); + [ping stop]; + ping = nil; + } fail:^(NSError *_Nonnull error) { + if (!ping) { + return; + } + reject(@(error.code).stringValue, error.domain, error); + [ping stop]; + ping = nil; + }]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(timeout * NSEC_PER_MSEC)), capturedQueue, ^{ + if (!ping) { + return; + } + ping = nil; + DEFINE_NSError(timeoutError, PingUtil_Message_Timeout) + reject(@(timeoutError.code).stringValue, timeoutError.domain, timeoutError); + }); + }]; +} + +@end diff --git a/native-modules/react-native-ping/package.json b/native-modules/react-native-ping/package.json new file mode 100644 index 00000000..96be5a6b --- /dev/null +++ b/native-modules/react-native-ping/package.json @@ -0,0 +1,93 @@ +{ + "name": "@onekeyfe/react-native-ping", + "version": "3.0.18", + "description": "react-native-ping TurboModule for OneKey", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "prepare": "bob build", + "typecheck": "tsc", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-ping.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-ping/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-ping#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "devDependencies": { + "@react-native/babel-preset": "0.83.0", + "react": "19.2.0", + "react-native": "0.83.0", + "react-native-builder-bob": "^0.40.17", + "typescript": "^5.9.2" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNPingSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rnping" + }, + "ios": { + "modulesProvider": { + "RNReactNativePing": "Ping" + } + } + } +} diff --git a/native-modules/react-native-ping/src/NativePing.ts b/native-modules/react-native-ping/src/NativePing.ts new file mode 100644 index 00000000..7d144160 --- /dev/null +++ b/native-modules/react-native-ping/src/NativePing.ts @@ -0,0 +1,8 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + start(ipAddress: string, option: { timeout?: number; payloadSize?: number }): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNReactNativePing'); diff --git a/native-modules/react-native-ping/src/index.tsx b/native-modules/react-native-ping/src/index.tsx new file mode 100644 index 00000000..c056d27b --- /dev/null +++ b/native-modules/react-native-ping/src/index.tsx @@ -0,0 +1,4 @@ +import NativePing from './NativePing'; + +export const Ping = NativePing; +export type { Spec as PingSpec } from './NativePing'; diff --git a/native-modules/react-native-ping/tsconfig.build.json b/native-modules/react-native-ping/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-ping/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-ping/tsconfig.json b/native-modules/react-native-ping/tsconfig.json new file mode 100644 index 00000000..97c1c7a3 --- /dev/null +++ b/native-modules/react-native-ping/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-ping": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-splash-screen/README.md b/native-modules/react-native-splash-screen/README.md new file mode 100644 index 00000000..3cffe3b5 --- /dev/null +++ b/native-modules/react-native-splash-screen/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-splash-screen + +First, a sincere thank-you to Crazycodeboy and the +`react-native-splash-screen` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[crazycodeboy/react-native-splash-screen](https://github.com/crazycodeboy/react-native-splash-screen). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-splash-screen`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-splash-screen` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [crazycodeboy/react-native-splash-screen](https://github.com/crazycodeboy/react-native-splash-screen) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to Crazycodeboy and everyone who contributes to +`react-native-splash-screen` 💙 diff --git a/native-modules/react-native-splash-screen/package.json b/native-modules/react-native-splash-screen/package.json index 34ad8be8..c3ef0b76 100644 --- a/native-modules/react-native-splash-screen/package.json +++ b/native-modules/react-native-splash-screen/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-splash-screen", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-splash-screen", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-modules/react-native-split-bundle-loader/SplitBundleLoader.podspec b/native-modules/react-native-split-bundle-loader/SplitBundleLoader.podspec new file mode 100644 index 00000000..1ec1c725 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/SplitBundleLoader.podspec @@ -0,0 +1,22 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "SplitBundleLoader" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-split-bundle-loader.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm,swift}" + s.public_header_files = "ios/SBLLogger.h" + + s.dependency 'ReactNativeNativeLogger' + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-split-bundle-loader/android/build.gradle b/native-modules/react-native-split-bundle-loader/android/build.gradle new file mode 100644 index 00000000..7c23f785 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['SplitBundleLoader_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["SplitBundleLoader_" + name]).toInteger() +} + +android { + namespace "com.splitbundleloader" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/AndroidManifest.xml b/native-modules/react-native-split-bundle-loader/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a2f47b60 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SBLLogger.kt b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SBLLogger.kt new file mode 100644 index 00000000..e868ebe5 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SBLLogger.kt @@ -0,0 +1,55 @@ +package com.splitbundleloader + +/** + * Lightweight logging wrapper that dynamically dispatches to OneKeyLog. + * Uses reflection to avoid a hard dependency on the native-logger module. + * Falls back to android.util.Log when OneKeyLog is not available. + * + * Mirrors iOS SBLLogger. + */ +object SBLLogger { + private const val TAG = "SplitBundleLoader" + + private val logClass: Class<*>? by lazy { + try { + Class.forName("com.margelo.nitro.nativelogger.OneKeyLog") + } catch (_: ClassNotFoundException) { + null + } + } + + private val methods by lazy { + val cls = logClass ?: return@lazy null + mapOf( + "debug" to cls.getMethod("debug", String::class.java, String::class.java), + "info" to cls.getMethod("info", String::class.java, String::class.java), + "warn" to cls.getMethod("warn", String::class.java, String::class.java), + "error" to cls.getMethod("error", String::class.java, String::class.java), + ) + } + + @JvmStatic + fun debug(message: String) = log("debug", message, android.util.Log.DEBUG) + + @JvmStatic + fun info(message: String) = log("info", message, android.util.Log.INFO) + + @JvmStatic + fun warn(message: String) = log("warn", message, android.util.Log.WARN) + + @JvmStatic + fun error(message: String) = log("error", message, android.util.Log.ERROR) + + private fun log(level: String, message: String, androidLogLevel: Int) { + val method = methods?.get(level) + if (method != null) { + try { + method.invoke(null, TAG, message) + return + } catch (_: Exception) { + // Fall through to android.util.Log + } + } + android.util.Log.println(androidLogLevel, TAG, message) + } +} diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt new file mode 100644 index 00000000..809df1ac --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderModule.kt @@ -0,0 +1,348 @@ +package com.splitbundleloader + +import android.content.Context +import android.content.res.AssetManager +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.util.concurrent.Semaphore + +/** + * TurboModule entry point for SplitBundleLoader. + * + * Provides two methods to JS: + * 1. getRuntimeBundleContext() — Returns current runtime's bundle paths and source kind + * 2. loadSegment(params) — Registers a HBC segment with the current Hermes runtime + * + * Mirrors iOS SplitBundleLoader.mm. + */ +@ReactModule(name = SplitBundleLoaderModule.NAME) +class SplitBundleLoaderModule(reactContext: ReactApplicationContext) : + NativeSplitBundleLoaderSpec(reactContext) { + + companion object { + const val NAME = "SplitBundleLoader" + private const val BUILTIN_EXTRACT_DIR = "onekey-builtin-segments" + // #18: Limit concurrent asset extractions to avoid I/O contention + private const val MAX_CONCURRENT_EXTRACTS = 2 + private val extractSemaphore = Semaphore(MAX_CONCURRENT_EXTRACTS) + } + + override fun getName(): String = NAME + + // ----------------------------------------------------------------------- + // getRuntimeBundleContext + // ----------------------------------------------------------------------- + + override fun getRuntimeBundleContext(promise: Promise) { + try { + val context = reactApplicationContext + val runtimeKind = "main" + var sourceKind = "builtin" + var bundleRoot = "" + var nativeVersion = "" + val bundleVersion = "" + + try { + nativeVersion = context.packageManager + .getPackageInfo(context.packageName, 0).versionName ?: "" + } catch (_: Exception) { + } + + // Check OTA bundle path + val otaBundlePath = getOtaBundlePath() + if (!otaBundlePath.isNullOrEmpty()) { + val otaFile = File(otaBundlePath) + if (otaFile.exists()) { + sourceKind = "ota" + bundleRoot = otaFile.parent ?: "" + } + } + + val builtinExtractRoot = File( + context.filesDir, + "$BUILTIN_EXTRACT_DIR/$nativeVersion" + ).absolutePath + + val result = Arguments.createMap() + result.putString("runtimeKind", runtimeKind) + result.putString("sourceKind", sourceKind) + result.putString("bundleRoot", bundleRoot) + result.putString("builtinExtractRoot", builtinExtractRoot) + result.putString("nativeVersion", nativeVersion) + result.putString("bundleVersion", bundleVersion) + + promise.resolve(result) + + // #17: Clean up old version extract directories asynchronously + cleanupOldExtractDirs(context, nativeVersion) + } catch (e: Exception) { + promise.reject("SPLIT_BUNDLE_CONTEXT_ERROR", e.message, e) + } + } + + // ----------------------------------------------------------------------- + // resolveSegmentPath (Phase 3) + // ----------------------------------------------------------------------- + + override fun resolveSegmentPath(relativePath: String, sha256: String, promise: Promise) { + try { + val absolutePath = resolveSegmentPath(relativePath, sha256) + if (absolutePath != null) { + promise.resolve(absolutePath) + } else { + promise.reject( + "SPLIT_BUNDLE_NOT_FOUND", + "Segment file not found: $relativePath" + ) + } + } catch (e: Exception) { + promise.reject("SPLIT_BUNDLE_RESOLVE_ERROR", e.message, e) + } + } + + // ----------------------------------------------------------------------- + // loadSegment + // ----------------------------------------------------------------------- + + override fun loadSegment( + segmentId: Double, + segmentKey: String, + relativePath: String, + sha256: String, + promise: Promise + ) { + // NOTE (#44): sha256 param is not verified at load time by design. + // Per §6.4.1, runtime trusts that OTA install has already verified + // segment integrity. Builtin segments are signed as part of the APK/IPA. + // If runtime SHA-256 verification is needed, add it here. + try { + val segId = segmentId.toInt() + + val absolutePath = resolveSegmentPath(relativePath, sha256) + if (absolutePath == null) { + promise.reject( + "SPLIT_BUNDLE_NOT_FOUND", + "Segment file not found: $relativePath (key=$segmentKey)" + ) + return + } + + // #19: Try CatalystInstance first (bridge mode), fall back to + // ReactHost registerSegment if available (bridgeless / new arch). + val reactContext = reactApplicationContext + val segStart = System.nanoTime() + if (reactContext.hasCatalystInstance()) { + reactContext.catalystInstance.registerSegment(segId, absolutePath) + val segMs = (System.nanoTime() - segStart) / 1_000_000.0 + SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) registered in ${String.format("%.1f", segMs)}ms") + promise.resolve(null) + } else { + // Bridgeless: try ReactHost via reflection + val registered = tryRegisterViaBridgeless(segId, absolutePath) + val segMs = (System.nanoTime() - segStart) / 1_000_000.0 + if (registered) { + SBLLogger.info("[SplitBundle] segment $segmentKey (id=$segId) registered via bridgeless in ${String.format("%.1f", segMs)}ms") + promise.resolve(null) + } else { + promise.reject( + "SPLIT_BUNDLE_NO_INSTANCE", + "Neither CatalystInstance nor ReactHost available" + ) + } + } + } catch (e: Exception) { + promise.reject("SPLIT_BUNDLE_LOAD_ERROR", e.message, e) + } + } + + // ----------------------------------------------------------------------- + // Bridgeless support (#19) + // ----------------------------------------------------------------------- + + private fun tryRegisterViaBridgeless(segmentId: Int, path: String): Boolean { + return try { + val appContext = reactApplicationContext.applicationContext + val appClass = appContext.javaClass + val hostMethod = appClass.getMethod("getReactHost") + val host = hostMethod.invoke(appContext) ?: return false + val registerMethod = host.javaClass.getMethod( + "registerSegment", Int::class.java, String::class.java + ) + registerMethod.invoke(host, segmentId, path) + true + } catch (_: Exception) { + false + } + } + + // ----------------------------------------------------------------------- + // Path resolution helpers + // ----------------------------------------------------------------------- + + /** + * Verify resolved path stays within the expected root directory (#45). + * Prevents path traversal via ".." components in relativePath. + */ + private fun isPathWithinRoot(root: File, resolved: File): Boolean { + return resolved.canonicalPath.startsWith(root.canonicalPath + File.separator) || + resolved.canonicalPath == root.canonicalPath + } + + private fun resolveSegmentPath(relativePath: String, expectedSha256: String): String? { + // Path traversal guard (#45) + if (relativePath.contains("..")) { + SBLLogger.warn("Path traversal rejected: $relativePath") + return null + } + + // 1. Try OTA bundle directory first + val otaBundlePath = getOtaBundlePath() + if (!otaBundlePath.isNullOrEmpty()) { + val otaRoot = File(otaBundlePath).parentFile + if (otaRoot != null) { + val candidate = File(otaRoot, relativePath) + if (candidate.exists() && isPathWithinRoot(otaRoot, candidate)) { + return candidate.absolutePath + } + } + } + + // 2. Try builtin: extract from assets if needed + return extractBuiltinSegmentIfNeeded(relativePath, expectedSha256) + } + + /** + * For Android builtin segments, APK assets can't be passed directly as file paths. + * Extract the asset to the extract cache directory on first access. + * + * #16: Validates extracted file size against the asset to detect truncated extractions. + * #18: Uses semaphore to limit concurrent extractions. + */ + private fun extractBuiltinSegmentIfNeeded(relativePath: String, expectedSha256: String): String? { + val context = reactApplicationContext + val nativeVersion = try { + context.packageManager + .getPackageInfo(context.packageName, 0).versionName ?: "unknown" + } catch (_: Exception) { + "unknown" + } + + val extractDir = File(context.filesDir, "$BUILTIN_EXTRACT_DIR/$nativeVersion") + val extractedFile = File(extractDir, relativePath) + + // #16: If file exists, verify it's not truncated by checking size against asset + if (extractedFile.exists()) { + val assetSize = getAssetSize(context.assets, relativePath) + if (assetSize >= 0 && extractedFile.length() == assetSize) { + return extractedFile.absolutePath + } + // Truncated or size mismatch — delete and re-extract + SBLLogger.warn("Extracted file size mismatch for $relativePath, re-extracting") + extractedFile.delete() + } + + // #18: Limit concurrent extractions + extractSemaphore.acquire() + try { + // Double-check after acquiring semaphore (another thread may have extracted) + if (extractedFile.exists()) { + return extractedFile.absolutePath + } + + val assets: AssetManager = context.assets + return try { + // Extract to temp file first, then atomically rename + val tempFile = File(extractedFile.parentFile, "${extractedFile.name}.tmp") + assets.open(relativePath).use { input -> + extractedFile.parentFile?.let { parent -> + if (!parent.exists()) parent.mkdirs() + } + FileOutputStream(tempFile).use { output -> + val buffer = ByteArray(8192) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + output.write(buffer, 0, len) + } + } + } + // Atomic rename prevents partial file observation + if (tempFile.renameTo(extractedFile)) { + extractedFile.absolutePath + } else { + tempFile.delete() + null + } + } catch (_: IOException) { + null + } + } finally { + extractSemaphore.release() + } + } + + /** + * Returns the size of an asset file, or -1 if it can't be determined. + */ + private fun getAssetSize(assets: AssetManager, assetPath: String): Long { + return try { + assets.openFd(assetPath).use { it.length } + } catch (_: IOException) { + // Asset may be compressed; fall back to reading the stream + try { + assets.open(assetPath).use { input -> + var size = 0L + val buffer = ByteArray(8192) + var len: Int + while (input.read(buffer).also { len = it } != -1) { + size += len + } + size + } + } catch (_: IOException) { + -1 + } + } + } + + /** + * #17: Asynchronously clean up extract directories from previous native versions. + */ + private fun cleanupOldExtractDirs(context: Context, currentVersion: String) { + Thread { + try { + val baseDir = File(context.filesDir, BUILTIN_EXTRACT_DIR) + if (!baseDir.exists() || !baseDir.isDirectory) return@Thread + val dirs = baseDir.listFiles() ?: return@Thread + for (dir in dirs) { + if (dir.isDirectory && dir.name != currentVersion) { + SBLLogger.info("Cleaning up old extract dir: ${dir.name}") + dir.deleteRecursively() + } + } + } catch (e: Exception) { + SBLLogger.warn("Failed to cleanup old extract dirs: ${e.message}") + } + }.start() + } + + private fun getOtaBundlePath(): String? { + return try { + val bundleUpdateStore = Class.forName( + "com.margelo.nitro.reactnativebundleupdate.BundleUpdateStoreAndroid" + ) + val method = bundleUpdateStore.getMethod( + "getCurrentBundleMainJSBundle", + Context::class.java + ) + val result = method.invoke(null, reactApplicationContext) + result?.toString() + } catch (_: Exception) { + null + } + } +} diff --git a/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderPackage.kt b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderPackage.kt new file mode 100644 index 00000000..d6e6d07a --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/android/src/main/java/com/splitbundleloader/SplitBundleLoaderPackage.kt @@ -0,0 +1,33 @@ +package com.splitbundleloader + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class SplitBundleLoaderPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == SplitBundleLoaderModule.NAME) { + SplitBundleLoaderModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[SplitBundleLoaderModule.NAME] = ReactModuleInfo( + SplitBundleLoaderModule.NAME, + SplitBundleLoaderModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-split-bundle-loader/babel.config.js b/native-modules/react-native-split-bundle-loader/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-split-bundle-loader/ios/SBLLogger.h b/native-modules/react-native-split-bundle-loader/ios/SBLLogger.h new file mode 100644 index 00000000..707b6d4a --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/ios/SBLLogger.h @@ -0,0 +1,16 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Lightweight logging wrapper that dynamically dispatches to OneKeyLog. +/// Avoids `@import ReactNativeNativeLogger` which fails in .mm (Objective-C++) files. +@interface SBLLogger : NSObject + ++ (void)debug:(NSString *)message; ++ (void)info:(NSString *)message; ++ (void)warn:(NSString *)message; ++ (void)error:(NSString *)message; + +@end + +NS_ASSUME_NONNULL_END diff --git a/native-modules/react-native-split-bundle-loader/ios/SBLLogger.m b/native-modules/react-native-split-bundle-loader/ios/SBLLogger.m new file mode 100644 index 00000000..986c68b2 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/ios/SBLLogger.m @@ -0,0 +1,42 @@ +#import "SBLLogger.h" + +static NSString *const kTag = @"SplitBundleLoader"; + +@implementation SBLLogger + ++ (void)debug:(NSString *)message { + [self _log:@"debug::" message:message]; +} + ++ (void)info:(NSString *)message { + [self _log:@"info::" message:message]; +} + ++ (void)warn:(NSString *)message { + [self _log:@"warn::" message:message]; +} + ++ (void)error:(NSString *)message { + [self _log:@"error::" message:message]; +} + +#pragma mark - Private + ++ (void)_log:(NSString *)selectorName message:(NSString *)message { + Class logClass = NSClassFromString(@"ReactNativeNativeLogger.OneKeyLog"); + if (!logClass) { + logClass = NSClassFromString(@"OneKeyLog"); + } + if (!logClass) { + return; + } + SEL sel = NSSelectorFromString(selectorName); + if (![logClass respondsToSelector:sel]) { + return; + } + typedef void (*LogFunc)(id, SEL, NSString *, NSString *); + LogFunc func = (LogFunc)[logClass methodForSelector:sel]; + func(logClass, sel, kTag, message); +} + +@end diff --git a/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.h b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.h new file mode 100644 index 00000000..816f1431 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.h @@ -0,0 +1,28 @@ +#import + +@interface SplitBundleLoader : NativeSplitBundleLoaderSpecBase + +- (void)getRuntimeBundleContext:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)loadSegment:(double)segmentId + segmentKey:(NSString *)segmentKey + relativePath:(NSString *)relativePath + sha256:(NSString *)sha256 + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)resolveSegmentPath:(NSString *)relativePath + sha256:(NSString *)sha256 + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; + +/// Evaluate a JS bundle file inside the given RCTHost's runtime. +/// Used for the common + entry split-bundle loading strategy: +/// 1. RCTHost boots with common.jsbundle (polyfills + shared modules) +/// 2. After the runtime is ready, this evaluates the entry-specific bundle +/// (main.jsbundle or background.bundle) via jsi::Runtime::evaluateJavaScript. +/// +/// @param bundlePath Absolute filesystem path to the bundle file. +/// @param host The RCTHost whose runtime should evaluate the bundle. ++ (void)loadEntryBundle:(NSString *)bundlePath inHost:(id)host; + +@end diff --git a/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm new file mode 100644 index 00000000..4bc44931 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/ios/SplitBundleLoader.mm @@ -0,0 +1,274 @@ +#import "SplitBundleLoader.h" +#import "SBLLogger.h" +#import +#import +#import +#import +#include + +@implementation SplitBundleLoader + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + [SBLLogger info:@"SplitBundleLoader module initialized"]; + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"SplitBundleLoader"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - OTA bundle path helper + +/// Safely retrieves the OTA bundle path via typed NSInvocation to avoid +/// performSelector ARC/signature issues (#15). ++ (nullable NSString *)otaBundlePath +{ + Class cls = NSClassFromString(@"ReactNativeBundleUpdate.BundleUpdateStore"); + if (!cls) return nil; + + SEL sel = NSSelectorFromString(@"currentBundleMainJSBundle"); + if (![cls respondsToSelector:sel]) return nil; + + NSMethodSignature *sig = [cls methodSignatureForSelector:sel]; + if (!sig || strcmp(sig.methodReturnType, @encode(id)) != 0) { + [SBLLogger warn:@"OTA method signature mismatch — skipping"]; + return nil; + } + + NSInvocation *inv = [NSInvocation invocationWithMethodSignature:sig]; + inv.target = cls; + inv.selector = sel; + [inv invoke]; + + __unsafe_unretained id rawResult = nil; + [inv getReturnValue:&rawResult]; + if (![rawResult isKindOfClass:[NSString class]]) return nil; + + NSString *result = (NSString *)rawResult; + if (result.length == 0) return nil; + + if ([result hasPrefix:@"file://"]) { + result = [[NSURL URLWithString:result] path]; + } + return result; +} + +// MARK: - Segment registration helper + +/// Registers a segment with the current runtime via bridgeless (RCTHost) architecture (#13). +/// +/// Thread safety (#57): This method is called from the TurboModule (JS thread). +/// No queue dispatch is needed. ++ (BOOL)registerSegment:(int)segmentId path:(NSString *)path error:(NSError **)outError +{ + // Bridgeless (New Architecture): get RCTHost via AppDelegate + id appDelegate = [UIApplication sharedApplication].delegate; + if ([appDelegate respondsToSelector:NSSelectorFromString(@"reactHost")]) { + RCTHost *host = [appDelegate performSelector:NSSelectorFromString(@"reactHost")]; + if (host && [host respondsToSelector:@selector(registerSegmentWithId:path:)]) { + [host registerSegmentWithId:@(segmentId) path:path]; + return YES; + } + } + + if (outError) { + *outError = [NSError errorWithDomain:@"SplitBundleLoader" + code:1 + userInfo:@{NSLocalizedDescriptionKey: @"RCTHost not available for segment registration"}]; + } + return NO; +} + +// MARK: - getRuntimeBundleContext + +- (void)getRuntimeBundleContext:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + @try { + NSString *runtimeKind = @"main"; + NSString *sourceKind = @"builtin"; + NSString *bundleRoot = @""; + NSString *nativeVersion = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleShortVersionString"] ?: @""; + NSString *bundleVersion = @""; + + NSString *otaPath = [SplitBundleLoader otaBundlePath]; + if (otaPath && [[NSFileManager defaultManager] fileExistsAtPath:otaPath]) { + sourceKind = @"ota"; + bundleRoot = [otaPath stringByDeletingLastPathComponent]; + } + + if ([sourceKind isEqualToString:@"builtin"]) { + bundleRoot = [[NSBundle mainBundle] resourcePath] ?: @""; + } + + resolve(@{ + @"runtimeKind": runtimeKind, + @"sourceKind": sourceKind, + @"bundleRoot": bundleRoot, + @"nativeVersion": nativeVersion, + @"bundleVersion": bundleVersion, + }); + } @catch (NSException *exception) { + reject(@"SPLIT_BUNDLE_CONTEXT_ERROR", exception.reason, nil); + } +} + +// MARK: - Path resolution helper + +/// Resolves a relative segment path to an absolute path, checking OTA then builtin. +/// Returns nil if the segment file is not found. ++ (nullable NSString *)resolveAbsolutePath:(NSString *)relativePath +{ + // 1. Try OTA bundle root first + NSString *otaPath = [SplitBundleLoader otaBundlePath]; + if (otaPath) { + NSString *otaRoot = [otaPath stringByDeletingLastPathComponent]; + NSString *candidate = [[otaRoot stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + if ([candidate hasPrefix:otaRoot] && + [[NSFileManager defaultManager] fileExistsAtPath:candidate]) { + return candidate; + } + } + + // 2. Fallback to builtin resource path + NSString *builtinRoot = [[NSBundle mainBundle] resourcePath]; + NSString *candidate = [[builtinRoot stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + if ([candidate hasPrefix:builtinRoot] && + [[NSFileManager defaultManager] fileExistsAtPath:candidate]) { + return candidate; + } + + return nil; +} + +// MARK: - resolveSegmentPath (Phase 3) + +- (void)resolveSegmentPath:(NSString *)relativePath + sha256:(NSString *)sha256 + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + @try { + if ([relativePath containsString:@".."]) { + reject(@"SPLIT_BUNDLE_INVALID_PATH", + [NSString stringWithFormat:@"Path traversal rejected: %@", relativePath], + nil); + return; + } + + NSString *absolutePath = [SplitBundleLoader resolveAbsolutePath:relativePath]; + if (absolutePath) { + resolve(absolutePath); + } else { + reject(@"SPLIT_BUNDLE_NOT_FOUND", + [NSString stringWithFormat:@"Segment file not found: %@", relativePath], + nil); + } + } @catch (NSException *exception) { + reject(@"SPLIT_BUNDLE_RESOLVE_ERROR", exception.reason, nil); + } +} + +// MARK: - loadEntryBundle (common + entry split loading) + ++ (void)loadEntryBundle:(NSString *)bundlePath inHost:(id)host +{ + if (!host || bundlePath.length == 0) { + [SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: invalid arguments (host=%@, path=%@)", host, bundlePath]]; + return; + } + + Ivar ivar = class_getInstanceVariable([host class], "_instance"); + if (!ivar) { + [SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: _instance ivar not found on %@", [host class]]]; + return; + } + + RCTInstance *instance = object_getIvar(host, ivar); + if (!instance) { + [SBLLogger warn:@"loadEntryBundle: _instance is nil"]; + return; + } + + NSData *data = [NSData dataWithContentsOfFile:bundlePath]; + if (!data || data.length == 0) { + [SBLLogger warn:[NSString stringWithFormat:@"loadEntryBundle: failed to read bundle at %@", bundlePath]]; + return; + } + + NSString *sourceURL = bundlePath.lastPathComponent ?: bundlePath; + CFAbsoluteTime startTime = CFAbsoluteTimeGetCurrent(); + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: evaluating %@ (%lu bytes)", sourceURL, (unsigned long)data.length]]; + + [instance callFunctionOnBufferedRuntimeExecutor:^(facebook::jsi::Runtime &runtime) { + @autoreleasepool { + CFAbsoluteTime evalStart = CFAbsoluteTimeGetCurrent(); + auto buffer = std::make_shared( + std::string(static_cast(data.bytes), data.length)); + runtime.evaluateJavaScript(std::move(buffer), [sourceURL UTF8String]); + double evalMs = (CFAbsoluteTimeGetCurrent() - evalStart) * 1000.0; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ evaluated in %.1fms", sourceURL, evalMs]]; + } + }]; + + double totalMs = (CFAbsoluteTimeGetCurrent() - startTime) * 1000.0; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] loadEntryBundle: %@ dispatched in %.1fms (eval is async)", sourceURL, totalMs]]; +} + +// MARK: - loadSegment + +- (void)loadSegment:(double)segmentId + segmentKey:(NSString *)segmentKey + relativePath:(NSString *)relativePath + sha256:(NSString *)sha256 + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + @try { + int segId = (int)segmentId; + CFAbsoluteTime segStart = CFAbsoluteTimeGetCurrent(); + + // Path traversal guard (#45) + if ([relativePath containsString:@".."]) { + reject(@"SPLIT_BUNDLE_INVALID_PATH", + [NSString stringWithFormat:@"Path traversal rejected: %@", relativePath], + nil); + return; + } + + NSString *absolutePath = [SplitBundleLoader resolveAbsolutePath:relativePath]; + + if (!absolutePath) { + reject(@"SPLIT_BUNDLE_NOT_FOUND", + [NSString stringWithFormat:@"Segment file not found: %@ (key=%@)", relativePath, segmentKey], + nil); + return; + } + + // Register segment (#13: supports both bridge and bridgeless) + NSError *regError = nil; + if ([SplitBundleLoader registerSegment:segId path:absolutePath error:®Error]) { + double segMs = (CFAbsoluteTimeGetCurrent() - segStart) * 1000.0; + [SBLLogger info:[NSString stringWithFormat:@"[SplitBundle] Loaded segment %@ (id=%d) in %.1fms", segmentKey, segId, segMs]]; + resolve(nil); + } else { + reject(@"SPLIT_BUNDLE_NO_RUNTIME", + regError.localizedDescription ?: @"Runtime not available", + regError); + } + } @catch (NSException *exception) { + reject(@"SPLIT_BUNDLE_LOAD_ERROR", + [NSString stringWithFormat:@"Failed to load segment %@: %@", segmentKey, exception.reason], + nil); + } +} + +@end diff --git a/native-modules/react-native-split-bundle-loader/package.json b/native-modules/react-native-split-bundle-loader/package.json new file mode 100644 index 00000000..ec26f604 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/package.json @@ -0,0 +1,168 @@ +{ + "name": "@onekeyfe/react-native-split-bundle-loader", + "version": "3.0.18", + "description": "react-native-split-bundle-loader", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "*.podspec", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-split-bundle-loader.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-split-bundle-loader/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-split-bundle-loader#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "SplitBundleLoaderSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.splitbundleloader" + }, + "ios": { + "modulesProvider": { + "SplitBundleLoader": "SplitBundleLoader" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-split-bundle-loader/src/NativeSplitBundleLoader.ts b/native-modules/react-native-split-bundle-loader/src/NativeSplitBundleLoader.ts new file mode 100644 index 00000000..11b7609b --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/src/NativeSplitBundleLoader.ts @@ -0,0 +1,23 @@ +import { TurboModuleRegistry } from 'react-native'; + +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + getRuntimeBundleContext(): Promise<{ + runtimeKind: string; + sourceKind: string; + bundleRoot: string; + builtinExtractRoot?: string; + nativeVersion: string; + bundleVersion?: string; + }>; + loadSegment( + segmentId: number, + segmentKey: string, + relativePath: string, + sha256: string, + ): Promise; + resolveSegmentPath(relativePath: string, sha256: string): Promise; +} + +export default TurboModuleRegistry.getEnforcing('SplitBundleLoader'); diff --git a/native-modules/react-native-split-bundle-loader/src/__tests__/index.test.tsx b/native-modules/react-native-split-bundle-loader/src/__tests__/index.test.tsx new file mode 100644 index 00000000..bf84291a --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/src/__tests__/index.test.tsx @@ -0,0 +1 @@ +it.todo('write a test'); diff --git a/native-modules/react-native-split-bundle-loader/src/index.tsx b/native-modules/react-native-split-bundle-loader/src/index.tsx new file mode 100644 index 00000000..8204180a --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/src/index.tsx @@ -0,0 +1,4 @@ +import NativeSplitBundleLoader from './NativeSplitBundleLoader'; + +export const SplitBundleLoader = NativeSplitBundleLoader; +export type { Spec as SplitBundleLoaderSpec } from './NativeSplitBundleLoader'; diff --git a/native-modules/react-native-split-bundle-loader/tsconfig.build.json b/native-modules/react-native-split-bundle-loader/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-split-bundle-loader/tsconfig.json b/native-modules/react-native-split-bundle-loader/tsconfig.json new file mode 100644 index 00000000..b38fdc5b --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-split-bundle-loader": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-split-bundle-loader/turbo.json b/native-modules/react-native-split-bundle-loader/turbo.json new file mode 100644 index 00000000..8b2bf087 --- /dev/null +++ b/native-modules/react-native-split-bundle-loader/turbo.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-tcp-socket/README.md b/native-modules/react-native-tcp-socket/README.md new file mode 100644 index 00000000..1a966fdb --- /dev/null +++ b/native-modules/react-native-tcp-socket/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-tcp-socket + +First, a sincere thank-you to Rapsssito and the +`react-native-tcp-socket` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[Rapsssito/react-native-tcp-socket](https://github.com/Rapsssito/react-native-tcp-socket). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-tcp-socket`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-tcp-socket` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [Rapsssito/react-native-tcp-socket](https://github.com/Rapsssito/react-native-tcp-socket) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to Rapsssito and everyone who contributes to +`react-native-tcp-socket` 💙 diff --git a/native-modules/react-native-tcp-socket/TcpSocket.podspec b/native-modules/react-native-tcp-socket/TcpSocket.podspec new file mode 100644 index 00000000..6a8cd071 --- /dev/null +++ b/native-modules/react-native-tcp-socket/TcpSocket.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "TcpSocket" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-tcp-socket.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-tcp-socket/android/build.gradle b/native-modules/react-native-tcp-socket/android/build.gradle new file mode 100644 index 00000000..35ef7756 --- /dev/null +++ b/native-modules/react-native-tcp-socket/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNTcpSocket_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNTcpSocket_" + name]).toInteger() +} + +android { + namespace "com.rntcpsocket" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketModule.kt b/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketModule.kt new file mode 100644 index 00000000..52474e6f --- /dev/null +++ b/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketModule.kt @@ -0,0 +1,35 @@ +package com.rntcpsocket + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.annotations.ReactModule +import java.net.InetSocketAddress +import java.net.Socket + +@ReactModule(name = RNTcpSocketModule.NAME) +class RNTcpSocketModule(reactContext: ReactApplicationContext) : + NativeTcpSocketSpec(reactContext) { + + companion object { + const val NAME = "RNTcpSocket" + } + + override fun getName(): String = NAME + + override fun connectWithTimeout(host: String, port: Double, timeoutMs: Double, promise: Promise) { + Thread { + try { + val portInt = port.toInt() + val timeout = timeoutMs.toInt() + val startTime = System.currentTimeMillis() + Socket().use { socket -> + socket.connect(InetSocketAddress(host, portInt), timeout) + } + val elapsed = System.currentTimeMillis() - startTime + promise.resolve(elapsed.toDouble()) + } catch (e: Exception) { + promise.reject("TCP_SOCKET_ERROR", e.message, e) + } + }.start() + } +} diff --git a/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketPackage.kt b/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketPackage.kt new file mode 100644 index 00000000..6b144e94 --- /dev/null +++ b/native-modules/react-native-tcp-socket/android/src/main/java/com/rntcpsocket/RNTcpSocketPackage.kt @@ -0,0 +1,33 @@ +package com.rntcpsocket + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class RNTcpSocketPackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == RNTcpSocketModule.NAME) { + RNTcpSocketModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[RNTcpSocketModule.NAME] = ReactModuleInfo( + RNTcpSocketModule.NAME, + RNTcpSocketModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-tcp-socket/babel.config.js b/native-modules/react-native-tcp-socket/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-tcp-socket/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-tcp-socket/ios/TcpSocket.h b/native-modules/react-native-tcp-socket/ios/TcpSocket.h new file mode 100644 index 00000000..f682676b --- /dev/null +++ b/native-modules/react-native-tcp-socket/ios/TcpSocket.h @@ -0,0 +1,5 @@ +#import + +@interface TcpSocket : NativeTcpSocketSpecBase + +@end diff --git a/native-modules/react-native-tcp-socket/ios/TcpSocket.mm b/native-modules/react-native-tcp-socket/ios/TcpSocket.mm new file mode 100644 index 00000000..96949c52 --- /dev/null +++ b/native-modules/react-native-tcp-socket/ios/TcpSocket.mm @@ -0,0 +1,151 @@ +#import "TcpSocket.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +@implementation TcpSocket + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNTcpSocket"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +// MARK: - connectWithTimeout + +- (void)connectWithTimeout:(NSString *)host + port:(double)port + timeoutMs:(double)timeoutMs + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSTimeInterval startTime = [[NSDate date] timeIntervalSince1970] * 1000.0; + + int sockfd = socket(AF_INET, SOCK_STREAM, 0); + if (sockfd < 0) { + reject(@"SOCKET_ERROR", @"Failed to create socket", nil); + return; + } + + // Set non-blocking mode + int flags = fcntl(sockfd, F_GETFL, 0); + if (flags < 0) { + close(sockfd); + reject(@"SOCKET_ERROR", @"Failed to get socket flags", nil); + return; + } + if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) < 0) { + close(sockfd); + reject(@"SOCKET_ERROR", @"Failed to set non-blocking mode", nil); + return; + } + + // Resolve hostname + struct addrinfo hints; + struct addrinfo *res = NULL; + memset(&hints, 0, sizeof(hints)); + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + + char portStr[16]; + snprintf(portStr, sizeof(portStr), "%d", (int)port); + + int gaiResult = getaddrinfo([host UTF8String], portStr, &hints, &res); + if (gaiResult != 0) { + close(sockfd); + reject(@"DNS_ERROR", + [NSString stringWithFormat:@"%s", gai_strerror(gaiResult)], + nil); + return; + } + + // Attempt non-blocking connect + int connectResult = connect(sockfd, res->ai_addr, res->ai_addrlen); + freeaddrinfo(res); + + if (connectResult < 0 && errno != EINPROGRESS) { + close(sockfd); + reject(@"CONNECT_ERROR", + [NSString stringWithUTF8String:strerror(errno)], + nil); + return; + } + + // If already connected (unlikely for non-blocking, but handle it) + if (connectResult == 0) { + close(sockfd); + NSTimeInterval elapsed = [[NSDate date] timeIntervalSince1970] * 1000.0 - startTime; + resolve(@((NSInteger)elapsed)); + return; + } + + // Wait for connection with select() and timeout + fd_set writeSet; + FD_ZERO(&writeSet); + FD_SET(sockfd, &writeSet); + + long timeoutLong = (long)timeoutMs; + struct timeval tv; + tv.tv_sec = timeoutLong / 1000; + tv.tv_usec = (int)((timeoutLong % 1000) * 1000); + + int selectResult = select(sockfd + 1, NULL, &writeSet, NULL, &tv); + + if (selectResult == 0) { + // Timeout + close(sockfd); + reject(@"ETIMEDOUT", @"Connection timeout", nil); + return; + } + + if (selectResult < 0) { + close(sockfd); + reject(@"SELECT_ERROR", + [NSString stringWithUTF8String:strerror(errno)], + nil); + return; + } + + // Check for socket-level error via getsockopt + int socketError = 0; + socklen_t optLen = sizeof(socketError); + if (getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &socketError, &optLen) < 0) { + close(sockfd); + reject(@"CONNECT_ERROR", @"Failed to get socket error", nil); + return; + } + + close(sockfd); + + if (socketError != 0) { + reject(@"CONNECT_ERROR", + [NSString stringWithUTF8String:strerror(socketError)], + nil); + return; + } + + NSTimeInterval elapsed = [[NSDate date] timeIntervalSince1970] * 1000.0 - startTime; + resolve(@((NSInteger)elapsed)); + }); +} + +@end diff --git a/native-modules/react-native-tcp-socket/package.json b/native-modules/react-native-tcp-socket/package.json new file mode 100644 index 00000000..b03f3eb2 --- /dev/null +++ b/native-modules/react-native-tcp-socket/package.json @@ -0,0 +1,163 @@ +{ + "name": "@onekeyfe/react-native-tcp-socket", + "version": "3.0.18", + "description": "react-native-tcp-socket", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "tcp", + "socket" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-tcp-socket.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-tcp-socket/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-tcp-socket#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNTcpSocketSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rntcpsocket" + }, + "ios": { + "modulesProvider": { + "RNTcpSocket": "TcpSocket" + } + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "type": "turbo-module", + "languages": "kotlin-objc", + "tools": [ + "eslint", + "jest", + "lefthook", + "release-it" + ], + "version": "0.56.0" + } +} diff --git a/native-modules/react-native-tcp-socket/src/NativeTcpSocket.ts b/native-modules/react-native-tcp-socket/src/NativeTcpSocket.ts new file mode 100644 index 00000000..c346be6e --- /dev/null +++ b/native-modules/react-native-tcp-socket/src/NativeTcpSocket.ts @@ -0,0 +1,17 @@ +import { TurboModuleRegistry } from 'react-native'; + +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + /** + * Attempt a TCP connection to host:port. + * Resolves with connection time in ms, rejects with error message. + */ + connectWithTimeout( + host: string, + port: number, + timeoutMs: number, + ): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNTcpSocket'); diff --git a/native-modules/react-native-tcp-socket/src/index.tsx b/native-modules/react-native-tcp-socket/src/index.tsx new file mode 100644 index 00000000..28d670ba --- /dev/null +++ b/native-modules/react-native-tcp-socket/src/index.tsx @@ -0,0 +1,57 @@ +import NativeTcpSocket from './NativeTcpSocket'; + +// Compatibility shim that mimics the react-native-tcp-socket createConnection API +// but uses a simple Promise underneath (works in background Hermes runtime) +export interface TcpSocketShim { + on(event: 'error', handler: (err: Error) => void): this; + on(event: 'timeout', handler: () => void): this; + destroy(): void; +} + +function createConnection( + options: { host: string; port: number; timeout?: number }, + connectCallback: () => void, +): TcpSocketShim { + const timeout = options.timeout ?? 5000; + const handlers: { error?: (err: Error) => void; timeout?: () => void } = {}; + + let destroyed = false; + + NativeTcpSocket.connectWithTimeout(options.host, options.port, timeout) + .then(() => { + if (!destroyed) connectCallback(); + }) + .catch((err: unknown) => { + if (!destroyed) { + const errMsg = + err instanceof Error ? err.message : String(err as string); + if ( + errMsg.includes('timeout') || + errMsg.includes('ETIMEDOUT') || + errMsg.includes('Connection timeout') + ) { + handlers.timeout?.(); + } else if (handlers.error) { + handlers.error(err instanceof Error ? err : new Error(errMsg)); + } + } + }); + + const shim = { + on(event: string, handler: (err?: Error) => void) { + if (event === 'error') { + handlers.error = handler as (err: Error) => void; + } else if (event === 'timeout') { + handlers.timeout = handler as () => void; + } + return shim as unknown as TcpSocketShim; + }, + destroy() { + destroyed = true; + }, + } as unknown as TcpSocketShim; + return shim; +} + +export default { createConnection }; +export { NativeTcpSocket }; diff --git a/native-modules/react-native-tcp-socket/tsconfig.build.json b/native-modules/react-native-tcp-socket/tsconfig.build.json new file mode 100644 index 00000000..34699441 --- /dev/null +++ b/native-modules/react-native-tcp-socket/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["lib"] +} diff --git a/native-modules/react-native-tcp-socket/tsconfig.json b/native-modules/react-native-tcp-socket/tsconfig.json new file mode 100644 index 00000000..db0e5b40 --- /dev/null +++ b/native-modules/react-native-tcp-socket/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "@onekeyfe/react-native-tcp-socket": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-tcp-socket/turbo.json b/native-modules/react-native-tcp-socket/turbo.json new file mode 100644 index 00000000..0a2c33d0 --- /dev/null +++ b/native-modules/react-native-tcp-socket/turbo.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_REMOVE_LEGACY_ARCH", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx" + ], + "outputs": [] + } + } +} diff --git a/native-modules/react-native-zip-archive/README.md b/native-modules/react-native-zip-archive/README.md new file mode 100644 index 00000000..5d471e61 --- /dev/null +++ b/native-modules/react-native-zip-archive/README.md @@ -0,0 +1,31 @@ +# @onekeyfe/react-native-zip-archive + +First, a sincere thank-you to Mockingbot and the +`react-native-zip-archive` maintainers for their excellent work 🙏 + +This package is built on, and inspired by, +[mockingbot/react-native-zip-archive](https://github.com/mockingbot/react-native-zip-archive). + +Our original plan was to keep our customizations as patches on top of upstream +`react-native-zip-archive`. + +As our product requirements evolved, the scope of those changes outgrew what we +could reasonably maintain as an upstream patch set. We regret that we were +unable to keep this work in patch form. + +As the gap grew, we ultimately forked `react-native-zip-archive` to keep +development and delivery stable. + +## Upstream Project + +- Repository: [mockingbot/react-native-zip-archive](https://github.com/mockingbot/react-native-zip-archive) +- License: MIT + +## Notes + +- This fork includes OneKey-specific adaptations for our product requirements. +- If you are looking for original behavior and full documentation, please refer + to the upstream repository. + +Thank you again to Mockingbot and everyone who contributes to +`react-native-zip-archive` 💙 diff --git a/native-modules/react-native-zip-archive/ZipArchive.podspec b/native-modules/react-native-zip-archive/ZipArchive.podspec new file mode 100644 index 00000000..998b79d6 --- /dev/null +++ b/native-modules/react-native-zip-archive/ZipArchive.podspec @@ -0,0 +1,21 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "ZipArchive" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-zip-archive.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + s.dependency 'SSZipArchive' + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-zip-archive/android/build.gradle b/native-modules/react-native-zip-archive/android/build.gradle new file mode 100644 index 00000000..bd91c31c --- /dev/null +++ b/native-modules/react-native-zip-archive/android/build.gradle @@ -0,0 +1,77 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['RNZipArchive_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" + +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["RNZipArchive_" + name]).toInteger() +} + +android { + namespace "com.rnziparchive" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + sourceSets { + main { + java.srcDirs += [ + "generated/java", + "generated/jni" + ] + } + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} diff --git a/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchiveModule.kt b/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchiveModule.kt new file mode 100644 index 00000000..c2f41853 --- /dev/null +++ b/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchiveModule.kt @@ -0,0 +1,158 @@ +package com.rnziparchive + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.module.annotations.ReactModule +import java.io.BufferedInputStream +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipInputStream +import java.util.zip.ZipOutputStream + +@ReactModule(name = RNZipArchiveModule.NAME) +class RNZipArchiveModule(reactContext: ReactApplicationContext) : + NativeZipArchiveSpec(reactContext) { + + companion object { + const val NAME = "RNZipArchive" + private const val BUFFER_SIZE = 8192 + } + + override fun getName(): String = NAME + + override fun isPasswordProtected(file: String, promise: Promise) { + Thread { + try { + // java.util.zip does not support password-protected zips natively + // Return false as a safe default + promise.resolve(false) + } catch (e: Exception) { + promise.reject("ZIP_ERROR", e.message, e) + } + }.start() + } + + override fun unzip(from: String, to: String, promise: Promise) { + Thread { + try { + val destDir = File(to) + if (!destDir.exists()) destDir.mkdirs() + + ZipInputStream(BufferedInputStream(FileInputStream(from))).use { zis -> + var entry: ZipEntry? = zis.nextEntry + while (entry != null) { + val outFile = File(destDir, entry.name) + if (entry.isDirectory) { + outFile.mkdirs() + } else { + outFile.parentFile?.mkdirs() + FileOutputStream(outFile).use { fos -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (zis.read(buffer).also { len = it } > 0) { + fos.write(buffer, 0, len) + } + } + } + zis.closeEntry() + entry = zis.nextEntry + } + } + promise.resolve(to) + } catch (e: Exception) { + promise.reject("ZIP_ERROR", e.message, e) + } + }.start() + } + + override fun unzipWithPassword(from: String, to: String, password: String, promise: Promise) { + Thread { + // java.util.zip does not support password-protected zip extraction + promise.reject("ZIP_ERROR", "Password-protected zip extraction is not supported on Android") + }.start() + } + + override fun zipFolder(from: String, to: String, promise: Promise) { + Thread { + try { + val sourceDir = File(from) + FileOutputStream(to).use { fos -> + ZipOutputStream(BufferedOutputStream(fos)).use { zos -> + zipDirectory(sourceDir, sourceDir.name, zos) + } + } + promise.resolve(to) + } catch (e: Exception) { + promise.reject("ZIP_ERROR", e.message, e) + } + }.start() + } + + override fun zipFiles(files: ReadableArray, to: String, promise: Promise) { + Thread { + try { + FileOutputStream(to).use { fos -> + ZipOutputStream(BufferedOutputStream(fos)).use { zos -> + for (i in 0 until files.size()) { + val filePath = files.getString(i) + val file = File(filePath) + if (file.exists()) { + addFileToZip(file, file.name, zos) + } + } + } + } + promise.resolve(to) + } catch (e: Exception) { + promise.reject("ZIP_ERROR", e.message, e) + } + }.start() + } + + override fun getUncompressedSize(path: String, promise: Promise) { + Thread { + try { + var totalSize = 0L + ZipFile(path).use { zipFile -> + val entries = zipFile.entries() + while (entries.hasMoreElements()) { + totalSize += entries.nextElement().size + } + } + promise.resolve(totalSize.toDouble()) + } catch (e: Exception) { + promise.reject("ZIP_ERROR", e.message, e) + } + }.start() + } + + private fun zipDirectory(dir: File, baseName: String, zos: ZipOutputStream) { + val files = dir.listFiles() ?: return + for (file in files) { + val entryName = "$baseName/${file.name}" + if (file.isDirectory) { + zipDirectory(file, entryName, zos) + } else { + addFileToZip(file, entryName, zos) + } + } + } + + private fun addFileToZip(file: File, entryName: String, zos: ZipOutputStream) { + val entry = ZipEntry(entryName) + zos.putNextEntry(entry) + FileInputStream(file).use { fis -> + val buffer = ByteArray(BUFFER_SIZE) + var len: Int + while (fis.read(buffer).also { len = it } > 0) { + zos.write(buffer, 0, len) + } + } + zos.closeEntry() + } +} diff --git a/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchivePackage.kt b/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchivePackage.kt new file mode 100644 index 00000000..b6349bdd --- /dev/null +++ b/native-modules/react-native-zip-archive/android/src/main/java/com/rnziparchive/RNZipArchivePackage.kt @@ -0,0 +1,33 @@ +package com.rnziparchive + +import com.facebook.react.BaseReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import java.util.HashMap + +class RNZipArchivePackage : BaseReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == RNZipArchiveModule.NAME) { + RNZipArchiveModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + val moduleInfos: MutableMap = HashMap() + moduleInfos[RNZipArchiveModule.NAME] = ReactModuleInfo( + RNZipArchiveModule.NAME, + RNZipArchiveModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // isCxxModule + true // isTurboModule + ) + moduleInfos + } + } +} diff --git a/native-modules/react-native-zip-archive/babel.config.js b/native-modules/react-native-zip-archive/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-zip-archive/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-zip-archive/ios/ZipArchive.h b/native-modules/react-native-zip-archive/ios/ZipArchive.h new file mode 100644 index 00000000..b17396b5 --- /dev/null +++ b/native-modules/react-native-zip-archive/ios/ZipArchive.h @@ -0,0 +1,10 @@ +#import +#import + +@interface ZipArchive : NativeZipArchiveSpecBase + +@property (nonatomic) NSString *processedFilePath; +@property (nonatomic) float progress; +@property (nonatomic, copy) void (^progressHandler)(NSUInteger entryNumber, NSUInteger total); + +@end diff --git a/native-modules/react-native-zip-archive/ios/ZipArchive.mm b/native-modules/react-native-zip-archive/ios/ZipArchive.mm new file mode 100644 index 00000000..2786ccbb --- /dev/null +++ b/native-modules/react-native-zip-archive/ios/ZipArchive.mm @@ -0,0 +1,182 @@ +#import "ZipArchive.h" + +@implementation ZipArchive +{ + bool hasListeners; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} + ++ (NSString *)moduleName +{ + return @"RNZipArchive"; +} + ++ (BOOL)requiresMainQueueSetup +{ + return NO; +} + +- (dispatch_queue_t)methodQueue +{ + return dispatch_queue_create("com.onekey.ZipArchiveQueue", DISPATCH_QUEUE_SERIAL); +} + +// MARK: - isPasswordProtected + +- (void)isPasswordProtected:(NSString *)file + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; + resolve([NSNumber numberWithBool:isPasswordProtected]); +} + +// MARK: - unzip + +- (void)unzip:(NSString *)from + to:(NSString *)to + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + self.progress = 0.0; + self.processedFilePath = @""; + + NSError *error = nil; + BOOL success = [SSZipArchive unzipFileAtPath:from + toDestination:to + preserveAttributes:NO + overwrite:YES + password:nil + error:&error + delegate:self]; + + self.progress = 1.0; + + if (success) { + resolve(to); + } else { + reject(@"unzip_error", [error localizedDescription], error); + } +} + +// MARK: - unzipWithPassword + +- (void)unzipWithPassword:(NSString *)from + to:(NSString *)to + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + self.progress = 0.0; + self.processedFilePath = @""; + + NSError *error = nil; + BOOL success = [SSZipArchive unzipFileAtPath:from + toDestination:to + preserveAttributes:NO + overwrite:YES + password:password + error:&error + delegate:self]; + + self.progress = 1.0; + + if (success) { + resolve(to); + } else { + reject(@"unzip_error", @"unable to unzip", error); + } +} + +// MARK: - zipFolder + +- (void)zipFolder:(NSString *)from + to:(NSString *)to + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + self.progress = 0.0; + self.processedFilePath = @""; + [self setProgressHandler]; + + BOOL success = [SSZipArchive createZipFileAtPath:to + withContentsOfDirectory:from + keepParentDirectory:NO + withPassword:nil + andProgressHandler:self.progressHandler]; + + self.progress = 1.0; + + if (success) { + resolve(to); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +// MARK: - zipFiles + +- (void)zipFiles:(NSArray *)files + to:(NSString *)to + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + self.progress = 0.0; + self.processedFilePath = @""; + [self setProgressHandler]; + + BOOL success = [SSZipArchive createZipFileAtPath:to withFilesAtPaths:files]; + + self.progress = 1.0; + + if (success) { + resolve(to); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +// MARK: - getUncompressedSize + +- (void)getUncompressedSize:(NSString *)path + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject +{ + NSError *error = nil; + NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; + + if (error == nil) { + resolve(wantedFileSize); + } else { + resolve(@-1); + } +} + +// MARK: - SSZipArchiveDelegate + +- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex + totalFiles:(NSInteger)totalFiles + archivePath:(NSString *)archivePath + unzippedFilePath:(NSString *)processedFilePath +{ + self.processedFilePath = processedFilePath; +} + +// MARK: - Progress helper + +- (void)setProgressHandler +{ + __weak ZipArchive *weakSelf = self; + self.progressHandler = ^(NSUInteger entryNumber, NSUInteger total) { + if (total > 0) { + weakSelf.progress = (float)entryNumber / (float)total; + } + }; +} + +@end diff --git a/native-modules/react-native-zip-archive/package.json b/native-modules/react-native-zip-archive/package.json new file mode 100644 index 00000000..997e2792 --- /dev/null +++ b/native-modules/react-native-zip-archive/package.json @@ -0,0 +1,93 @@ +{ + "name": "@onekeyfe/react-native-zip-archive", + "version": "3.0.18", + "description": "react-native-zip-archive TurboModule for OneKey", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "ios", + "*.podspec", + "!ios/build", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "android", + "!**/*.map" + ], + "scripts": { + "prepare": "bob build", + "typecheck": "tsc", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-zip-archive.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-zip-archive/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-zip-archive#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "devDependencies": { + "@react-native/babel-preset": "0.83.0", + "react": "19.2.0", + "react-native": "0.83.0", + "react-native-builder-bob": "^0.40.17", + "typescript": "^5.9.2" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "RNZipArchiveSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.rnziparchive" + }, + "ios": { + "modulesProvider": { + "RNZipArchive": "ZipArchive" + } + } + } +} diff --git a/native-modules/react-native-zip-archive/src/NativeZipArchive.ts b/native-modules/react-native-zip-archive/src/NativeZipArchive.ts new file mode 100644 index 00000000..d2b5ad80 --- /dev/null +++ b/native-modules/react-native-zip-archive/src/NativeZipArchive.ts @@ -0,0 +1,13 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + isPasswordProtected(file: string): Promise; + unzip(from: string, to: string): Promise; + unzipWithPassword(from: string, to: string, password: string): Promise; + zipFolder(from: string, to: string): Promise; + zipFiles(files: string[], to: string): Promise; + getUncompressedSize(path: string): Promise; +} + +export default TurboModuleRegistry.getEnforcing('RNZipArchive'); diff --git a/native-modules/react-native-zip-archive/src/index.tsx b/native-modules/react-native-zip-archive/src/index.tsx new file mode 100644 index 00000000..7cc64316 --- /dev/null +++ b/native-modules/react-native-zip-archive/src/index.tsx @@ -0,0 +1,6 @@ +import NativeZipArchive from './NativeZipArchive'; + +export const ZipArchive = NativeZipArchive; +export const zip = (source: string, target: string): Promise => + NativeZipArchive.zipFolder(source, target); +export type { Spec as ZipArchiveSpec } from './NativeZipArchive'; diff --git a/native-modules/react-native-zip-archive/tsconfig.build.json b/native-modules/react-native-zip-archive/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-zip-archive/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-zip-archive/tsconfig.json b/native-modules/react-native-zip-archive/tsconfig.json new file mode 100644 index 00000000..f00fb714 --- /dev/null +++ b/native-modules/react-native-zip-archive/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "react-native-zip-archive": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-views/react-native-auto-size-input/package.json b/native-views/react-native-auto-size-input/package.json index ed6e1866..2218b8b8 100644 --- a/native-views/react-native-auto-size-input/package.json +++ b/native-views/react-native-auto-size-input/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-auto-size-input", - "version": "1.1.46", + "version": "3.0.18", "description": "Auto-sizing text input with font scaling, prefix and suffix support", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-views/react-native-pager-view/android/src/main/AndroidManifest.xml b/native-views/react-native-pager-view/android/src/main/AndroidManifest.xml index cfe65132..ed8b453c 100644 --- a/native-views/react-native-pager-view/android/src/main/AndroidManifest.xml +++ b/native-views/react-native-pager-view/android/src/main/AndroidManifest.xml @@ -1,4 +1,4 @@ + > diff --git a/native-views/react-native-pager-view/package.json b/native-views/react-native-pager-view/package.json index 78aee6f8..0df87fa5 100644 --- a/native-views/react-native-pager-view/package.json +++ b/native-views/react-native-pager-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-pager-view", - "version": "1.1.46", + "version": "3.0.18", "description": "React Native wrapper for Android and iOS ViewPager", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -29,7 +29,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli lib", @@ -59,7 +60,7 @@ "devDependencies": { "@types/react": "^19.2.0", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "typescript": "^5.9.2" }, diff --git a/native-views/react-native-scroll-guard/package.json b/native-views/react-native-scroll-guard/package.json index 28e52e01..51bd5f6a 100644 --- a/native-views/react-native-scroll-guard/package.json +++ b/native-views/react-native-scroll-guard/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-scroll-guard", - "version": "1.1.46", + "version": "3.0.18", "description": "A native view wrapper that prevents parent scrollable containers (PagerView/ViewPager2) from intercepting child scroll gestures", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build lib", @@ -63,7 +64,7 @@ "del-cli": "^6.0.0", "nitrogen": "0.31.10", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "typescript": "^5.9.2" diff --git a/native-views/react-native-skeleton/package.json b/native-views/react-native-skeleton/package.json index c8a72ada..4ca614ff 100644 --- a/native-views/react-native-skeleton/package.json +++ b/native-views/react-native-skeleton/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-skeleton", - "version": "1.1.46", + "version": "3.0.18", "description": "react-native-skeleton", "main": "./lib/module/index.js", "types": "./lib/typescript/src/index.d.ts", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", @@ -80,7 +81,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/native-views/react-native-tab-view/package.json b/native-views/react-native-tab-view/package.json index 3ce8e199..4924ed2b 100644 --- a/native-views/react-native-tab-view/package.json +++ b/native-views/react-native-tab-view/package.json @@ -1,6 +1,6 @@ { "name": "@onekeyfe/react-native-tab-view", - "version": "1.1.46", + "version": "3.0.18", "description": "Native Bottom Tabs for React Native (UIKit implementation)", "source": "./src/index.tsx", "main": "./lib/module/index.js", @@ -31,7 +31,8 @@ "!**/__tests__", "!**/__fixtures__", "!**/__mocks__", - "!**/.*" + "!**/.*", + "!**/*.map" ], "scripts": { "clean": "del-cli lib", @@ -61,7 +62,7 @@ "devDependencies": { "@types/react": "^19.2.0", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "typescript": "^5.9.2" }, diff --git a/package.json b/package.json index 5c64ff81..3a094923 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "nitrogen": "0.31.10", "prettier": "^2.8.8", "react": "19.2.0", - "react-native": "0.83.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", "react-native-builder-bob": "^0.40.13", "react-native-nitro-modules": "0.33.2", "release-it": "^19.0.4", diff --git a/yarn.lock b/yarn.lock index d1558606..4a98b9b6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2779,7 +2779,7 @@ __metadata: jest: "npm:^29.6.3" prettier: "npm:2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-mmkv: "npm:^4.1.2" react-native-nitro-modules: "npm:0.33.2" react-native-safe-area-context: "npm:^5.5.2" @@ -2813,7 +2813,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -2826,6 +2826,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-aes-crypto@workspace:native-modules/react-native-aes-crypto": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-aes-crypto@workspace:native-modules/react-native-aes-crypto" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-app-update@workspace:*, @onekeyfe/react-native-app-update@workspace:native-modules/react-native-app-update": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-app-update@workspace:native-modules/react-native-app-update" @@ -2849,7 +2882,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -2862,6 +2895,40 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-async-storage@workspace:native-modules/react-native-async-storage": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-async-storage@workspace:native-modules/react-native-async-storage" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + merge-options: "npm:^3.0.4" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-auto-size-input@workspace:*, @onekeyfe/react-native-auto-size-input@workspace:native-views/react-native-auto-size-input": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-auto-size-input@workspace:native-views/react-native-auto-size-input" @@ -2885,7 +2952,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -2920,7 +2987,7 @@ __metadata: lefthook: "npm:^2.0.3" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.17" release-it: "npm:^19.0.4" turbo: "npm:^2.5.6" @@ -2954,7 +3021,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -2990,7 +3057,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3003,6 +3070,21 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-cloud-fs@workspace:native-modules/react-native-cloud-fs": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-cloud-fs@workspace:native-modules/react-native-cloud-fs" + dependencies: + "@react-native/babel-preset": "npm:0.83.0" + react: "npm:19.2.0" + react-native: "npm:0.83.0" + react-native-builder-bob: "npm:^0.40.17" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-cloud-kit-module@workspace:*, @onekeyfe/react-native-cloud-kit-module@workspace:native-modules/react-native-cloud-kit-module": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-cloud-kit-module@workspace:native-modules/react-native-cloud-kit-module" @@ -3026,7 +3108,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3062,7 +3144,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3075,6 +3157,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-dns-lookup@workspace:native-modules/react-native-dns-lookup": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-dns-lookup@workspace:native-modules/react-native-dns-lookup" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-get-random-values@workspace:*, @onekeyfe/react-native-get-random-values@workspace:native-modules/react-native-get-random-values": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-get-random-values@workspace:native-modules/react-native-get-random-values" @@ -3098,7 +3213,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3134,7 +3249,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3169,7 +3284,7 @@ __metadata: lefthook: "npm:^2.0.3" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" release-it: "npm:^19.0.4" turbo: "npm:^2.5.6" @@ -3203,7 +3318,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3216,13 +3331,46 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-network-info@workspace:native-modules/react-native-network-info": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-network-info@workspace:native-modules/react-native-network-info" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-pager-view@workspace:*, @onekeyfe/react-native-pager-view@workspace:native-views/react-native-pager-view": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-pager-view@workspace:native-views/react-native-pager-view" dependencies: "@types/react": "npm:^19.2.0" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" typescript: "npm:^5.9.2" peerDependencies: @@ -3231,6 +3379,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-pbkdf2@workspace:native-modules/react-native-pbkdf2": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-pbkdf2@workspace:native-modules/react-native-pbkdf2" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-perf-memory@workspace:*, @onekeyfe/react-native-perf-memory@workspace:native-modules/react-native-perf-memory": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-perf-memory@workspace:native-modules/react-native-perf-memory" @@ -3254,7 +3435,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3267,6 +3448,21 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-ping@workspace:native-modules/react-native-ping": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-ping@workspace:native-modules/react-native-ping" + dependencies: + "@react-native/babel-preset": "npm:0.83.0" + react: "npm:19.2.0" + react-native: "npm:0.83.0" + react-native-builder-bob: "npm:^0.40.17" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-scroll-guard@workspace:*, @onekeyfe/react-native-scroll-guard@workspace:native-views/react-native-scroll-guard": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-scroll-guard@workspace:native-views/react-native-scroll-guard" @@ -3275,7 +3471,7 @@ __metadata: del-cli: "npm:^6.0.0" nitrogen: "npm:0.31.10" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" typescript: "npm:^5.9.2" @@ -3309,7 +3505,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3345,7 +3541,7 @@ __metadata: nitrogen: "npm:0.31.10" prettier: "npm:^2.8.8" react: "npm:19.2.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" react-native-nitro-modules: "npm:0.33.2" release-it: "npm:^19.0.4" @@ -3358,6 +3554,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-split-bundle-loader@workspace:native-modules/react-native-split-bundle-loader": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-split-bundle-loader@workspace:native-modules/react-native-split-bundle-loader" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-tab-view@workspace:*, @onekeyfe/react-native-tab-view@workspace:native-views/react-native-tab-view": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-tab-view@workspace:native-views/react-native-tab-view" @@ -3365,7 +3594,7 @@ __metadata: "@types/react": "npm:^19.2.0" react: "npm:19.2.0" react-freeze: "npm:^1.0.0" - react-native: "npm:0.83.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" react-native-builder-bob: "npm:^0.40.13" sf-symbols-typescript: "npm:^2.0.0" typescript: "npm:^5.9.2" @@ -3376,6 +3605,54 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-tcp-socket@workspace:native-modules/react-native-tcp-socket": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-tcp-socket@workspace:native-modules/react-native-tcp-socket" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + +"@onekeyfe/react-native-zip-archive@workspace:native-modules/react-native-zip-archive": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-zip-archive@workspace:native-modules/react-native-zip-archive" + dependencies: + "@react-native/babel-preset": "npm:0.83.0" + react: "npm:19.2.0" + react-native: "npm:0.83.0" + react-native-builder-bob: "npm:^0.40.17" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@phun-ky/typeof@npm:2.0.3": version: 2.0.3 resolution: "@phun-ky/typeof@npm:2.0.3" @@ -8208,6 +8485,13 @@ __metadata: languageName: node linkType: hard +"is-plain-obj@npm:^2.1.0": + version: 2.1.0 + resolution: "is-plain-obj@npm:2.1.0" + checksum: 10/cec9100678b0a9fe0248a81743041ed990c2d4c99f893d935545cfbc42876cbe86d207f3b895700c690ad2fa520e568c44afc1605044b535a7820c1d40e38daa + languageName: node + linkType: hard + "is-regex@npm:^1.2.1": version: 1.2.1 resolution: "is-regex@npm:1.2.1" @@ -9586,6 +9870,15 @@ __metadata: languageName: node linkType: hard +"merge-options@npm:^3.0.4": + version: 3.0.4 + resolution: "merge-options@npm:3.0.4" + dependencies: + is-plain-obj: "npm:^2.1.0" + checksum: 10/d86ddb3dd6e85d558dbf25dc944f3527b6bacb944db3fdda6e84a3f59c4e4b85231095f58b835758b9a57708342dee0f8de0dffa352974a48221487fe9f4584f + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -11206,6 +11499,57 @@ __metadata: languageName: node linkType: hard +"react-native@patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch": + version: 0.83.0 + resolution: "react-native@patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch::version=0.83.0&hash=f8560f" + dependencies: + "@jest/create-cache-key-function": "npm:^29.7.0" + "@react-native/assets-registry": "npm:0.83.0" + "@react-native/codegen": "npm:0.83.0" + "@react-native/community-cli-plugin": "npm:0.83.0" + "@react-native/gradle-plugin": "npm:0.83.0" + "@react-native/js-polyfills": "npm:0.83.0" + "@react-native/normalize-colors": "npm:0.83.0" + "@react-native/virtualized-lists": "npm:0.83.0" + abort-controller: "npm:^3.0.0" + anser: "npm:^1.4.9" + ansi-regex: "npm:^5.0.0" + babel-jest: "npm:^29.7.0" + babel-plugin-syntax-hermes-parser: "npm:0.32.0" + base64-js: "npm:^1.5.1" + commander: "npm:^12.0.0" + flow-enums-runtime: "npm:^0.0.6" + glob: "npm:^7.1.1" + hermes-compiler: "npm:0.14.0" + invariant: "npm:^2.2.4" + jest-environment-node: "npm:^29.7.0" + memoize-one: "npm:^5.0.0" + metro-runtime: "npm:^0.83.3" + metro-source-map: "npm:^0.83.3" + nullthrows: "npm:^1.1.1" + pretty-format: "npm:^29.7.0" + promise: "npm:^8.3.0" + react-devtools-core: "npm:^6.1.5" + react-refresh: "npm:^0.14.0" + regenerator-runtime: "npm:^0.13.2" + scheduler: "npm:0.27.0" + semver: "npm:^7.1.3" + stacktrace-parser: "npm:^0.1.10" + whatwg-fetch: "npm:^3.0.0" + ws: "npm:^7.5.10" + yargs: "npm:^17.6.2" + peerDependencies: + "@types/react": ^19.1.1 + react: ^19.2.0 + peerDependenciesMeta: + "@types/react": + optional: true + bin: + react-native: cli.js + checksum: 10/7cbda17babe8957971b509c9ef33af857c8f051d11f03c2636259752355ad64691b52f9af86efa854cf99dd19bf9bdb409ed239a91ed72b6909583ec996cb0d5 + languageName: node + linkType: hard + "react-refresh@npm:^0.14.0": version: 0.14.2 resolution: "react-refresh@npm:0.14.2"