From 121655c9468a0719c721388951c7027287c72b69 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 18:09:22 +0800 Subject: [PATCH 01/10] ci: run native Android and iOS unit tests --- .github/workflows/native-tests.yml | 211 +++++++++++++ example/react-native/android/settings.gradle | 4 + example/react-native/ios/Podfile | 6 + example/react-native/ios/Podfile.lock | 295 +++++++++++++----- example/react-native/package.json | 1 + .../SegmentArtifactSweepTest.kt | 9 +- .../react-native-image/android/build.gradle | 1 + yarn.lock | 3 +- 8 files changed, 443 insertions(+), 87 deletions(-) create mode 100644 .github/workflows/native-tests.yml diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml new file mode 100644 index 000000000..04d2cecf2 --- /dev/null +++ b/.github/workflows/native-tests.yml @@ -0,0 +1,211 @@ +name: native-tests + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: native-tests-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-unit: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: yarn + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: gradle/actions/setup-gradle@v4 + - name: Install and generate native bindings + run: | + corepack enable + yarn install --immutable + # shellcheck disable=SC2016 + find native-modules native-views -mindepth 2 -maxdepth 2 -name nitro.json -print0 | + xargs -0 -n1 -P4 bash -c 'cd "$(dirname "$0")" && yarn nitrogen > /dev/null' + - name: Run seven Android unit test suites + working-directory: example/react-native/android + run: | + ./gradlew --continue --no-daemon --max-workers=2 --console=plain \ + :onekeyfe_react-native-app-update:testDebugUnitTest \ + :onekeyfe_react-native-network-throttle:testDebugUnitTest \ + :onekeyfe_react-native-range-downloader:testDebugUnitTest \ + :onekeyfe_react-native-sni-connect:testDebugUnitTest \ + :onekeyfe_react-native-auto-size-input:testDebugUnitTest \ + :onekeyfe_react-native-image:testDebugUnitTest \ + :onekeyfe_react-native-native-list:testDebugUnitTest + - name: Verify every Android suite executed tests + if: always() + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + packages = { + 'native-modules/react-native-app-update', + 'native-modules/react-native-network-throttle', + 'native-modules/react-native-range-downloader', + 'native-modules/react-native-sni-connect', + 'native-views/react-native-auto-size-input', + 'native-views/react-native-image', + 'native-views/react-native-native-list', + } + total = 0 + for package in sorted(packages): + reports = list((Path(package) / 'android/build/test-results/testDebugUnitTest').glob('TEST-*.xml')) + count = sum(int(ET.parse(report).getroot().attrib['tests']) for report in reports) + print(f'{package}: {count} tests in {len(reports)} reports') + if count == 0: + raise SystemExit(f'No executed tests for {package}') + total += count + print(f'Android unit tests executed: {total}') + PY + - name: Upload Android test reports on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: android-native-test-reports + path: | + native-modules/*/android/build/test-results/testDebugUnitTest/*.xml + native-views/*/android/build/test-results/testDebugUnitTest/*.xml + example/react-native/android/build/reports/problems/ + if-no-files-found: warn + + kotlin-cli: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - name: Install verified Kotlin 2.3.21 compiler + run: | + curl -fsSLo kotlin-compiler.zip https://github.com/JetBrains/kotlin/releases/download/v2.3.21/kotlin-compiler-2.3.21.zip + curl -fsSLo kotlin-compiler.sha256 https://github.com/JetBrains/kotlin/releases/download/v2.3.21/kotlin-compiler-2.3.21.zip.sha256 + echo "$(cat kotlin-compiler.sha256) kotlin-compiler.zip" | sha256sum --check + unzip -q kotlin-compiler.zip -d "$RUNNER_TEMP" + "$RUNNER_TEMP/kotlinc/bin/kotlinc" -version 2>&1 | grep -F '2.3.21' + - name: Run bundle-update Kotlin policy tests + working-directory: native-modules/react-native-bundle-update + run: | + "$RUNNER_TEMP/kotlinc/bin/kotlinc" \ + android/src/main/java/com/margelo/nitro/reactnativebundleupdate/BundleStorageSchemaPolicy.kt \ + tests/kotlin/BundleStorageSchemaPolicyTest.kt \ + -include-runtime -d "$RUNNER_TEMP/bundle-storage-schema-tests.jar" + java -jar "$RUNNER_TEMP/bundle-storage-schema-tests.jar" | tee "$RUNNER_TEMP/kotlin-tests.log" + grep -Eq 'BundleStorageSchemaPolicy: [1-9][0-9]* assertions passed' "$RUNNER_TEMP/kotlin-tests.log" + + swiftpm: + runs-on: macos-26 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - name: Select and verify Xcode 26.6 + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + xcodebuild -version + xcodebuild -version | grep -F 'Xcode 26.6' + - name: Run three SwiftPM suites and verify execution + shell: bash + run: | + set -euo pipefail + mkdir -p artifacts/swiftpm + packages=( + native-modules/react-native-sni-connect + native-modules/react-native-bundle-update + native-modules/react-native-range-downloader/tests/swiftpm + ) + for package in "${packages[@]}"; do + name=$(basename "$package") + if [[ "$package" == *range-downloader* ]]; then name=range-downloader; fi + swift test --package-path "$package" --scratch-path "$RUNNER_TEMP/swift-$name" \ + 2>&1 | tee "artifacts/swiftpm/$name.log" + if ! grep -Eq 'Executed [1-9][0-9]* tests?|Test run with [1-9][0-9]* tests?' "artifacts/swiftpm/$name.log"; then + echo "No executed Swift tests for $package" >&2 + exit 1 + fi + done + - name: Upload SwiftPM logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: swiftpm-native-test-logs + path: artifacts/swiftpm/*.log + if-no-files-found: warn + + ios-xctest: + runs-on: macos-26 + timeout-minutes: 90 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: '24.x' + cache: yarn + - uses: ruby/setup-ruby@v1 + with: + working-directory: example/react-native + ruby-version: '3.4.4' + bundler-cache: true + - name: Select and verify Xcode and simulator + run: | + sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + xcodebuild -version | grep -F 'Xcode 26.6' + xcrun simctl list devices available | grep -F 'iOS 26.5' + xcrun simctl list devices available | grep -F 'iPhone 17 Pro (' + df -h . + - name: Install and generate native bindings + run: | + corepack enable + yarn install --immutable + # shellcheck disable=SC2016 + find native-modules native-views -mindepth 2 -maxdepth 2 -name nitro.json -print0 | + xargs -0 -n1 -P4 bash -c 'cd "$(dirname "$0")" && yarn nitrogen > /dev/null' + - name: Install CocoaPods test specs from lockfile + working-directory: example/react-native/ios + run: bundle exec pod install --deployment + - name: Run four pod XCTest suites on iOS Simulator + shell: bash + run: | + set -euo pipefail + mkdir -p artifacts/ios + workspace=example/react-native/ios/example.xcworkspace + schemes=(OneKeyImage-Unit-Tests NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests) + xcodebuild -list -workspace "$workspace" > artifacts/ios/schemes.log + for scheme in "${schemes[@]}"; do + grep -Fxq " $scheme" artifacts/ios/schemes.log + # shellcheck disable=SC2016 + xcodebuild test -workspace "$workspace" -scheme "$scheme" \ + -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.5' \ + -derivedDataPath "$RUNNER_TEMP/native-tests-derived-data" \ + -resultBundlePath "$PWD/artifacts/ios/$scheme.xcresult" \ + -parallel-testing-enabled NO -jobs 2 CODE_SIGNING_ALLOWED=NO \ + IPHONEOS_DEPLOYMENT_TARGET=16.4 \ + 'OTHER_LDFLAGS=$(inherited) -lc++' \ + 2>&1 | tee "artifacts/ios/$scheme.log" + xcrun xcresulttool get test-results summary \ + --path "artifacts/ios/$scheme.xcresult" \ + > "artifacts/ios/$scheme-summary.json" + jq -e '.totalTestCount > 0' "artifacts/ios/$scheme-summary.json" + done + df -h . + - name: Upload iOS result bundles and logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: ios-native-test-results + path: artifacts/ios/ + if-no-files-found: warn diff --git a/example/react-native/android/settings.gradle b/example/react-native/android/settings.gradle index d30f9c41c..3d1620dad 100644 --- a/example/react-native/android/settings.gradle +++ b/example/react-native/android/settings.gradle @@ -44,6 +44,10 @@ extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> rootProject.name = 'example' include ':app' +// This package is not an example app dependency, but its unit tests use the same Gradle host. +include ':onekeyfe_react-native-network-throttle' +project(':onekeyfe_react-native-network-throttle').projectDir = file('../../../native-modules/react-native-network-throttle/android') + def reactNativeGradlePlugin = new File( providers.exec { workingDir(rootDir) diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index aace63b28..c347976c9 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -41,6 +41,12 @@ target 'example' do pod 'SDWebImageSVGCoder', '1.7.0', :modular_headers => true pod 'SDWebImageWebPCoder', '0.14.6', :modular_headers => true + # CocoaPods does not install test specs unless they are requested explicitly. + pod 'OneKeyImage', :path => '../../../node_modules/@onekeyfe/react-native-image', :testspecs => ['Tests'] + pod 'NativeSheet', :path => '../../../node_modules/@onekeyfe/react-native-native-sheet', :testspecs => ['Tests'] + pod 'react-native-pager-view', :path => '../../../node_modules/@onekeyfe/react-native-pager-view', :testspecs => ['Tests'] + pod 'OneKeyTextInput', :path => '../../../node_modules/@onekeyfe/react-native-text-input', :testspecs => ['Tests'] + # Copy optional offline TradingView chart material into the app bundle as # `tradingview-assets/`, preserving the # directory tree so react-native-chart-webview's WKURLSchemeHandler can serve it. diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index b4f3768ec..4af0a5bd9 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.116): + - AesCrypto (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -21,7 +21,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AsyncStorage (3.0.116): + - AsyncStorage (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -43,7 +43,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - AutoSizeInput (3.0.116): + - AutoSizeInput (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -67,7 +67,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - BackgroundThread (3.0.116): + - BackgroundThread (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -90,7 +90,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ChartWebview (3.0.116): + - ChartWebview (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -115,7 +115,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - CloudFs (3.0.116): + - CloudFs (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -137,7 +137,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - CloudKitModule (3.0.116): + - CloudKitModule (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -165,7 +165,7 @@ PODS: - CocoaLumberjack/Core (3.9.1) - CocoaLumberjack/Swift (3.9.1): - CocoaLumberjack/Core - - DnsLookup (3.0.116): + - DnsLookup (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -194,7 +194,7 @@ PODS: - hermes-engine (250829098.0.16): - hermes-engine/Pre-built (= 250829098.0.16) - hermes-engine/Pre-built (250829098.0.16) - - KeychainModule (3.0.116): + - KeychainModule (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -234,7 +234,51 @@ PODS: - MMKV (2.4.0): - MMKVCore (~> 2.4.0) - MMKVCore (2.4.0) - - NetworkInfo (3.0.116): + - NativeSheet (3.0.156): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - NativeSheet/Tests (3.0.156): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - NetworkInfo (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -304,7 +348,35 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - OneKeyImage (3.0.116): + - OneKeyImage (3.0.156): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - SDWebImage (~> 5.21.7) + - SDWebImageSVGCoder (~> 1.7.0) + - SDWebImageWebPCoder (~> 0.14.6) + - Skeleton (= 3.0.156) + - Yoga + - OneKeyImage/Tests (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -330,11 +402,13 @@ PODS: - SDWebImage (~> 5.21.7) - SDWebImageSVGCoder (~> 1.7.0) - SDWebImageWebPCoder (~> 0.14.6) - - Skeleton (= 3.0.116) + - Skeleton (= 3.0.156) - Yoga - - OneKeyTextInput (3.0.116): + - OneKeyTextInput (3.0.156): + - React-Core + - OneKeyTextInput/Tests (3.0.156): - React-Core - - Pbkdf2 (3.0.116): + - Pbkdf2 (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -356,7 +430,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - PerpDepthBar (3.0.116): + - PerpDepthBar (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -380,7 +454,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Ping (3.0.116): + - Ping (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1825,10 +1899,10 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-native-list (3.0.116): + - react-native-native-list (3.0.156): - hermes-engine - NitroModules - - OneKeyImage (= 3.0.116) + - OneKeyImage (= 3.0.156) - RCTRequired - RCTTypeSafety - React-callinvoker @@ -1850,7 +1924,29 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-pager-view (3.0.116): + - react-native-pager-view (3.0.156): + - hermes-engine + - RCTRequired + - RCTTypeSafety + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga + - react-native-pager-view/Tests (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1941,7 +2037,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view (3.0.116): + - react-native-tab-view (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -1953,7 +2049,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.116) + - react-native-tab-view/common (= 3.0.156) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -1964,7 +2060,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - react-native-tab-view/common (3.0.116): + - react-native-tab-view/common (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2382,7 +2478,7 @@ PODS: - React-perflogger (= 0.86.2) - React-utils (= 0.86.2) - ReactNativeDependencies - - ReactNativeAppUpdate (3.0.116): + - ReactNativeAppUpdate (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2407,7 +2503,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleCrypto (3.0.116): + - ReactNativeBundleCrypto (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2432,7 +2528,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeBundleUpdate (3.0.116): + - ReactNativeBundleUpdate (3.0.156): - hermes-engine - MMKV (= 2.4.0) - NitroModules @@ -2461,7 +2557,7 @@ PODS: - ReactNativeRangeDownloader - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.116): + - ReactNativeCheckBiometricAuthChanged (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2487,7 +2583,32 @@ PODS: - ReactNativeNativeLogger - Yoga - ReactNativeDependencies (0.86.2) - - ReactNativeDeviceUtils (3.0.116): + - ReactNativeDeviceUtils (3.0.156): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactNativeNativeLogger + - Yoga + - ReactNativeGetRandomValues (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2512,7 +2633,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeGetRandomValues (3.0.116): + - ReactNativeImageCropPicker (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2537,7 +2658,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeLiteCard (3.0.116): + - ReactNativeLiteCard (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2560,7 +2681,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeNativeLogger (3.0.116): + - ReactNativeNativeLogger (3.0.156): - CocoaLumberjack/Swift (~> 3.8) - hermes-engine - NitroModules @@ -2585,7 +2706,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ReactNativePerfMemory (3.0.116): + - ReactNativePerfMemory (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2610,7 +2731,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativePerfStats (3.0.116): + - ReactNativePerfStats (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2635,7 +2756,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeRangeDownloader (3.0.116): + - ReactNativeRangeDownloader (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2661,7 +2782,7 @@ PODS: - ReactNativeNativeLogger - SSZipArchive (= 2.5.5) - Yoga - - ReactNativeSplashScreen (3.0.116): + - ReactNativeSplashScreen (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2686,7 +2807,7 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga - - ReactNativeZipArchive (3.0.116): + - ReactNativeZipArchive (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2757,7 +2878,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - ScrollGuard (3.0.116): + - ScrollGuard (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2789,7 +2910,7 @@ PODS: - SDWebImageWebPCoder (0.14.6): - libwebp (~> 1.0) - SDWebImage/Core (~> 5.17) - - SegmentSlider (3.0.116): + - SegmentSlider (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2813,7 +2934,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - Skeleton (3.0.116): + - Skeleton (3.0.156): - hermes-engine - NitroModules - RCTRequired @@ -2837,7 +2958,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SniConnect (3.0.116): + - SniConnect (3.0.156): - EMASCurl (= 1.5.5) - hermes-engine - RCTRequired @@ -2861,7 +2982,7 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga - - SplitBundleLoader (3.0.116): + - SplitBundleLoader (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2885,7 +3006,7 @@ PODS: - ReactNativeNativeLogger - Yoga - SSZipArchive (2.5.5) - - TcpSocket (3.0.116): + - TcpSocket (3.0.156): - hermes-engine - RCTRequired - RCTTypeSafety @@ -2922,11 +3043,15 @@ DEPENDENCIES: - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - "KeychainModule (from `../../../node_modules/@onekeyfe/react-native-keychain-module`)" + - "NativeSheet (from `../../../node_modules/@onekeyfe/react-native-native-sheet`)" + - "NativeSheet/Tests (from `../../../node_modules/@onekeyfe/react-native-native-sheet`)" - "NetworkInfo (from `../../../node_modules/@onekeyfe/react-native-network-info`)" - NitroMmkv (from `../../../node_modules/react-native-mmkv`) - NitroModules (from `../../../node_modules/react-native-nitro-modules`) - "OneKeyImage (from `../../../node_modules/@onekeyfe/react-native-image`)" + - "OneKeyImage/Tests (from `../../../node_modules/@onekeyfe/react-native-image`)" - "OneKeyTextInput (from `../../../node_modules/@onekeyfe/react-native-text-input`)" + - "OneKeyTextInput/Tests (from `../../../node_modules/@onekeyfe/react-native-text-input`)" - "Pbkdf2 (from `../../../node_modules/@onekeyfe/react-native-pbkdf2`)" - "PerpDepthBar (from `../../../node_modules/@onekeyfe/react-native-perp-depth-bar`)" - "Ping (from `../../../node_modules/@onekeyfe/react-native-ping`)" @@ -2970,6 +3095,7 @@ DEPENDENCIES: - React-mutationobservernativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) - "react-native-native-list (from `../../../node_modules/@onekeyfe/react-native-native-list`)" - "react-native-pager-view (from `../../../node_modules/@onekeyfe/react-native-pager-view`)" + - "react-native-pager-view/Tests (from `../../../node_modules/@onekeyfe/react-native-pager-view`)" - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - "react-native-tab-view (from `../../../node_modules/@onekeyfe/react-native-tab-view`)" - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) @@ -3013,6 +3139,7 @@ DEPENDENCIES: - ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) - "ReactNativeDeviceUtils (from `../../../node_modules/@onekeyfe/react-native-device-utils`)" - "ReactNativeGetRandomValues (from `../../../node_modules/@onekeyfe/react-native-get-random-values`)" + - "ReactNativeImageCropPicker (from `../../../node_modules/@onekeyfe/react-native-image-crop-picker`)" - "ReactNativeLiteCard (from `../../../node_modules/@onekeyfe/react-native-lite-card`)" - "ReactNativeNativeLogger (from `../../../node_modules/@onekeyfe/react-native-native-logger`)" - "ReactNativePerfMemory (from `../../../node_modules/@onekeyfe/react-native-perf-memory`)" @@ -3069,6 +3196,8 @@ EXTERNAL SOURCES: :tag: hermes-v250829098.0.16 KeychainModule: :path: "../../../node_modules/@onekeyfe/react-native-keychain-module" + NativeSheet: + :path: "../../../node_modules/@onekeyfe/react-native-native-sheet" NetworkInfo: :path: "../../../node_modules/@onekeyfe/react-native-network-info" NitroMmkv: @@ -3249,6 +3378,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@onekeyfe/react-native-device-utils" ReactNativeGetRandomValues: :path: "../../../node_modules/@onekeyfe/react-native-get-random-values" + ReactNativeImageCropPicker: + :path: "../../../node_modules/@onekeyfe/react-native-image-crop-picker" ReactNativeLiteCard: :path: "../../../node_modules/@onekeyfe/react-native-lite-card" ReactNativeNativeLogger: @@ -3281,30 +3412,31 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 24dd5b1d850add715f9bce25305a740a36c72db2 - AsyncStorage: ec7d495bf4fdc739e6e5a857fee3a21340fbcc7b - AutoSizeInput: ddd21d683d14e45652782cef61d55eca996411b3 - BackgroundThread: 7e5ae29b10106ed3cd483efeda7a6fef8336cdf9 - ChartWebview: 3f6c75a0362d04223bbbd7c078c65fc44ff8ebfe - CloudFs: c8e4dd6c81a0a9da2fe9b39bc034d40e2f02f406 - CloudKitModule: 16e9d0dadf71c1e2e397c641402e26a4beeeef87 + AesCrypto: b218a7ed52263e5eb3ac292938e1b78337a40213 + AsyncStorage: 15c9e8d2981048843744ed853dfed15c52599f5b + AutoSizeInput: 1acc4f267b9f7342443b55188a8c9a1c8ab33373 + BackgroundThread: 12296fefa3fbcd7bfd9e477340543c6ae7241fa3 + ChartWebview: d5fa9b4edf9c932995302bc923a79e3432c6f988 + CloudFs: 3098537768abe6f0cd37cec296147ae305dea1ce + CloudKitModule: 8d429f27eb714bdc40679c321d8d9c96c6ce7d8f CocoaLumberjack: e4ba3b414dfca8c1916c6303d37f63b3a95134c6 - DnsLookup: 33be7457f302f568fd2dd026cfc8e0f5970c0468 + DnsLookup: d2b99c2e1e62e445a6a985ee0a2952f8bd2c79ba EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f FBLazyVector: 3c3be9a019176b5699455f7f66c5444b78a411c6 - hermes-engine: 56e32d0a992f46124883b1307b5258ebb5fad674 - KeychainModule: 330d73bda7786e409780bce78d25875d9390ab5b + hermes-engine: eb14761560c3097b15973a402c116da6187ab9af + KeychainModule: 63cfc0ebbe8fc30a2812f5b9bf2df644fa55e411 libwebp: af5937a13536ef73f3784d69cc342b98961acf33 MMKV: 86859fdfa2b0b21db1fd6e48788474a6416a2c77 MMKVCore: 3d16ce9f7d411e135020915fde98a056859a1efa - NetworkInfo: a3f9ebb899d70b4bbdb5676e53f88811118512ab + NativeSheet: 80cf8a2a7a1cd5af66146246c6d675636c169f93 + NetworkInfo: 08833b9ee1ad3aad57f650bf2c03462260f15854 NitroMmkv: 85a251e15c39d7c95fe58cf40786fa126b8d143c NitroModules: cda827b31eb05c1bfcc0e2bbd9b647a69c6c314f - OneKeyImage: ce0e74ccb60726e03be1f45fd46111e2909d8a2e - OneKeyTextInput: 0da37cc6dca11530af2c97f873fadb39b7d94fa2 - Pbkdf2: cf01d070d754c64fbb8ddf2472fa8ccce6326e1a - PerpDepthBar: 3ee93b6a61a179b0c73e867d522fb71240bb8f9b - Ping: 0fb0bfc573d78d35f6320d67ed9800cdff3c17cd + OneKeyImage: ba73c4c40feb208a6354b7475fdc9f80f7c6391c + OneKeyTextInput: 2c9a210190cf78c409e06ec204ce7db02885d0f5 + Pbkdf2: 752f795b47ca96e89d263f05d3e0917393b4195d + PerpDepthBar: 15e24c04a0f0ff817855b6cccf5d2ffec5a38277 + Ping: bb21d3858f71884460a7716306888687ed5398df RCTDeprecation: bccb6545c26db881ecddfd83a3f9ea82aba1605f RCTRequired: b2f74764d596fc0051f00fee94b49bf41a6f7f5a RCTSwiftUI: c6d6a31b849b9dfa64c33b55dc91ac15dd55774c @@ -3342,10 +3474,10 @@ SPEC CHECKSUMS: React-Mapbuffer: 44dd007f917e2396afa0e70bbd70273237d93fde React-microtasksnativemodule: f413ee411341fb3c34d20e85e9f6f524921fab15 React-mutationobservernativemodule: 58f6d2adf7d98e72b241608dfa2ddce414d2a4e7 - react-native-native-list: f4afedde8718e6bcfeaec1a962e2e411255d3574 - react-native-pager-view: 663f01948535f3c74b22e5623c68db0425b13e96 + react-native-native-list: ee4f08215477be36d96d80a3473ab7567e1a3994 + react-native-pager-view: c41389cfc805ce60b5980924a480f982ae261e7f react-native-safe-area-context: 91a90d98c310adcc90a511e5aeb6046d7c19d885 - react-native-tab-view: 5909314c78067dde80de50bba9335722fb36f86c + react-native-tab-view: b62691e2b0492c55dc436cf2f971c27f4a0165cc React-NativeModulesApple: 167e532fb9bae30f3c66636b8ee0092bab263667 React-networking: 89f6b69755a3176f30e56ba1ba8e214b1518ecfb React-oscompat: db6675ddaef3bddd1b41533627f3d26ec710c05f @@ -3380,33 +3512,34 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: 0e13d430eadac8a2ef18515a860d5c59df05b475 ReactCodegen: 01cbf7a08c30e8b71ad71489bb56b5bed195fa5e ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 - ReactNativeAppUpdate: 76d4bf579b8c90d8bb3ee31b8791325171907607 - ReactNativeBundleCrypto: 52005dd3e3a6fc3358bb8e7faccdf091f875bcda - ReactNativeBundleUpdate: 26919b82ffea99e467aed7abd091f5c4b87c820e - ReactNativeCheckBiometricAuthChanged: 0dcdc1b9fce2cda1b90f03aff7e0659b6c420c6d + ReactNativeAppUpdate: 4880dff75e3bf33305882760cccaec0bccff2d21 + ReactNativeBundleCrypto: aa732a62ba36fffbd8906383db5b4d01a15d039e + ReactNativeBundleUpdate: f07c516f08f30dfecc7c36c63495ca984642442c + ReactNativeCheckBiometricAuthChanged: 6b9c67db94c2a3b9f8454710d7a1eaf4e3b3e2a0 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf - ReactNativeDeviceUtils: b5bea91d77c2c8b03783b1413a90fce8025b7bfb - ReactNativeGetRandomValues: e4d8c0223825aee1d5b31733b0aee5219a841adc - ReactNativeLiteCard: 390f3b44af84ab9801a2e9aad87d1751e59be777 - ReactNativeNativeLogger: f222216fb353d379330cd05875de79b44f1237cf - ReactNativePerfMemory: a1ea4577ad2a843f5e003d633aa025a7c6e3eb87 - ReactNativePerfStats: 8215e062b7f142b30677a03863ba309c58bf24d1 - ReactNativeRangeDownloader: dab3d0299dd790262987c512ee50dfb2423f0fdb - ReactNativeSplashScreen: f6e70a7a744d847fc033def537737b313fb94931 - ReactNativeZipArchive: d52ed5cbc374099ec5f8dbd780c4a634bcc10067 + ReactNativeDeviceUtils: 5b6d250ff5b8c8ab60412d65c9f264e23966a6be + ReactNativeGetRandomValues: a742b388d17ed96ddafbdaa7a373fc4fa546b1c9 + ReactNativeImageCropPicker: 7b5924895c249de7f4fcf5f2a1b9d20f8b2268b4 + ReactNativeLiteCard: b79da2fd6d8b9b4b937bbfff3639fb84527fa59c + ReactNativeNativeLogger: a65544512307eaa7d837231f1e8e5dd81f6f3924 + ReactNativePerfMemory: b524752f61b1ffd81f6205f608d82862619e257f + ReactNativePerfStats: f9c8c23d82c4ce4141647327d6e6e3c1b4e6a75e + ReactNativeRangeDownloader: 82f0be0eb546c9310fd34e7aabdd4baf0932f2f5 + ReactNativeSplashScreen: 5fe6902a2e6e6dc6d909ee12fcdb89a2311297d7 + ReactNativeZipArchive: 2f6962133b7662e7254c55bddd79eb96310ee574 RNScreens: 01b065ded2dfe7987bcce770ff3a196be417ff41 - ScrollGuard: 6c7802eb8c2eae7d3594ff18d87149a34545f8e3 + ScrollGuard: 1da2fe550eae86b3dc4393ca9836ff55793329d7 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 - SegmentSlider: 1c1cb079b77143019bbdf60d9dddccdc194a0f8a - Skeleton: 70270d1209c9eb0f7046f71bbd752589d74cbf56 - SniConnect: b787ef81aef8a9ad081d0c502230a9ad0940ea9a - SplitBundleLoader: bcd7a486fbbcf5df4563692137de0e5019d587b0 + SegmentSlider: 993d0b85c00e9796e2ed2239fe9439817dcaa6cc + Skeleton: 176bd1b01d4b91bebca3d314d7eeb8f70311da69 + SniConnect: 35e5df5eceecd12ed462ef2421733ca27a6484ef + SplitBundleLoader: ba12571a4af23184777fe5a85983ff27e9c4c55c SSZipArchive: c69881e8ac5521f0e622291387add5f60f30f3c4 - TcpSocket: 1f49789925bf9f06c9478e275fa9e161429b60d3 + TcpSocket: 2725be5301911fb4a3bcc4f85915dcacfd0ccfb1 Yoga: 9891cfb1c680f64de9c41b09e7453dea33454d2b -PODFILE CHECKSUM: 7a691f3433a6d77193e9da2e90067502224c4f6f +PODFILE CHECKSUM: 6d7345646c6ab6ae641cea028c7600f99b4a7364 -COCOAPODS: 1.17.0 +COCOAPODS: 1.16.2 diff --git a/example/react-native/package.json b/example/react-native/package.json index a9fd10cce..08f48e27f 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -33,6 +33,7 @@ "@onekeyfe/react-native-lite-card": "workspace:*", "@onekeyfe/react-native-native-list": "workspace:*", "@onekeyfe/react-native-native-logger": "workspace:*", + "@onekeyfe/react-native-native-sheet": "workspace:*", "@onekeyfe/react-native-network-info": "workspace:*", "@onekeyfe/react-native-pager-view": "workspace:*", "@onekeyfe/react-native-pbkdf2": "workspace:*", diff --git a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt index 06f8773e0..0edd3e660 100644 --- a/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt +++ b/native-modules/react-native-range-downloader/android/src/test/java/com/margelo/nitro/reactnativerangedownloader/SegmentArtifactSweepTest.kt @@ -286,8 +286,8 @@ class SegmentArtifactSweepTest { assertEquals(listOf(10, 25, 50, 100), emitted) } - // Under genuine concurrency the CAS gate still never emits a value below the - // running max, and emits each percentage at most once. Many threads race the + // Under genuine concurrency the CAS gate never decreases the published max + // and accepts each percentage at most once. Many threads race the // same gate driven by the REAL progressPercent + AtomicInteger CAS. @Test fun progressGateNeverGoesBackwardUnderConcurrency() { @@ -328,11 +328,10 @@ class SegmentArtifactSweepTest { // The published max ends at exactly 100 and never regressed. assertEquals("gate must settle at 100%", 100, last.get()) assertEquals("published max must never regress", 0, regress.get()) - // Each emitted percentage is unique (CAS de-dup) and the stream is sorted - // ascending (monotone): emissions are appended only on a successful advance. + // Each successful CAS is unique. Queue insertion happens after CAS, so + // concurrent threads can append those values in a different order. val list = emissions.toList() assertEquals("every emission must be unique (no re-emit of an equal value)", list.toSet().size, list.size) - assertEquals("emissions must be monotone non-decreasing", list.sorted(), list) assertTrue("the terminal 100% must be emitted exactly once", list.count { it == 100 } == 1) } diff --git a/native-views/react-native-image/android/build.gradle b/native-views/react-native-image/android/build.gradle index f6bed3669..bd44e3220 100644 --- a/native-views/react-native-image/android/build.gradle +++ b/native-views/react-native-image/android/build.gradle @@ -56,6 +56,7 @@ android { } buildFeatures { buildConfig true; prefab true } buildTypes { release { minifyEnabled false } } + testOptions { unitTests.returnDefaultValues = true } lintOptions { disable "GradleCompatible" } compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/yarn.lock b/yarn.lock index 4dd9a8011..4d31dfa42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2962,6 +2962,7 @@ __metadata: "@onekeyfe/react-native-lite-card": "workspace:*" "@onekeyfe/react-native-native-list": "workspace:*" "@onekeyfe/react-native-native-logger": "workspace:*" + "@onekeyfe/react-native-native-sheet": "workspace:*" "@onekeyfe/react-native-network-info": "workspace:*" "@onekeyfe/react-native-pager-view": "workspace:*" "@onekeyfe/react-native-pbkdf2": "workspace:*" @@ -3734,7 +3735,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-native-sheet@workspace:native-views/react-native-native-sheet": +"@onekeyfe/react-native-native-sheet@workspace:*, @onekeyfe/react-native-native-sheet@workspace:native-views/react-native-native-sheet": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-native-sheet@workspace:native-views/react-native-native-sheet" dependencies: From 12fa6f42c4b4b8f4bc6d7451e520cc5c0fa38131 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 18:12:45 +0800 Subject: [PATCH 02/10] ci: enable Corepack before using Yarn --- .github/workflows/native-tests.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 04d2cecf2..b0db92e9a 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -21,7 +21,6 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '24.x' - cache: yarn - uses: actions/setup-java@v4 with: distribution: temurin @@ -154,7 +153,6 @@ jobs: - uses: actions/setup-node@v6 with: node-version: '24.x' - cache: yarn - uses: ruby/setup-ruby@v1 with: working-directory: example/react-native From def9cbb7b5fcb4e3106584c6be235b003ddac54e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 18:27:56 +0800 Subject: [PATCH 03/10] ci: use CocoaPods CDN and verify portable lockfile --- .github/workflows/native-tests.yml | 34 +++++++++++++++++++++++++-- example/react-native/ios/Podfile | 2 +- example/react-native/ios/Podfile.lock | 4 ++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index b0db92e9a..5546d0575 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -172,9 +172,39 @@ jobs: # shellcheck disable=SC2016 find native-modules native-views -mindepth 2 -maxdepth 2 -name nitro.json -print0 | xargs -0 -n1 -P4 bash -c 'cd "$(dirname "$0")" && yarn nitrogen > /dev/null' - - name: Install CocoaPods test specs from lockfile + - name: Install CocoaPods test specs and verify lockfile working-directory: example/react-native/ios - run: bundle exec pod install --deployment + run: | + bundle exec pod install + ruby - <<'RUBY' + require 'yaml' + + def normalized_lockfile(text) + lockfile = YAML.safe_load(text, permitted_classes: [Symbol], aliases: true) + checksums = lockfile.fetch('SPEC CHECKSUMS') + checksum = checksums.fetch('hermes-engine') + abort 'Invalid Hermes podspec checksum' unless checksum.match?(/\A[0-9a-f]{40}\z/) + checksums['hermes-engine'] = '' + + # CocoaPods may retain the previous Specs Git URL in the lockfile + # even when the Podfile now resolves the same specs through trunk CDN. + repos = lockfile.fetch('SPEC REPOS') + git_specs = repos.delete('https://github.com/CocoaPods/Specs.git') + if git_specs + repos['trunk'] = ((repos['trunk'] || []) + git_specs).sort + end + lockfile + end + + committed = IO.popen( + ['git', 'show', 'HEAD:example/react-native/ios/Podfile.lock'], &:read + ) + installed = File.read('Podfile.lock') + unless normalized_lockfile(committed) == normalized_lockfile(installed) + abort 'CocoaPods changed the locked dependencies or checksums' + end + puts 'Podfile.lock matches: only the Hermes checksum and Specs CDN URL may differ' + RUBY - name: Run four pod XCTest suites on iOS Simulator shell: bash run: | diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index c347976c9..f6193c957 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -1,6 +1,6 @@ # Spec sources. The default CDN plus Aliyun's repo, which hosts EMASCurl # (a transitive native dependency of @onekeyfe/react-native-sni-connect). -source 'https://github.com/CocoaPods/Specs.git' +source 'https://cdn.cocoapods.org/' source 'https://github.com/aliyun/aliyun-specs.git' # Resolve react_native_pods.rb with node to allow for hoisting diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 4af0a5bd9..cf85f90d5 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -3162,7 +3162,7 @@ DEPENDENCIES: SPEC REPOS: https://github.com/aliyun/aliyun-specs.git: - EMASCurl - https://github.com/CocoaPods/Specs.git: + trunk: - CocoaLumberjack - libwebp - MMKV @@ -3540,6 +3540,6 @@ SPEC CHECKSUMS: TcpSocket: 2725be5301911fb4a3bcc4f85915dcacfd0ccfb1 Yoga: 9891cfb1c680f64de9c41b09e7453dea33454d2b -PODFILE CHECKSUM: 6d7345646c6ab6ae641cea028c7600f99b4a7364 +PODFILE CHECKSUM: 225cc4ef7243271a31edb13b0c0b95d857fdcd05 COCOAPODS: 1.16.2 From a34b939264fcfbac40e9eaba777a454df67e6000 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 18:44:07 +0800 Subject: [PATCH 04/10] ci: run available iOS XCTest suites before image --- .github/workflows/native-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index 5546d0575..b593d43f4 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -211,7 +211,7 @@ jobs: set -euo pipefail mkdir -p artifacts/ios workspace=example/react-native/ios/example.xcworkspace - schemes=(OneKeyImage-Unit-Tests NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests) + schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests OneKeyImage-Unit-Tests) xcodebuild -list -workspace "$workspace" > artifacts/ios/schemes.log for scheme in "${schemes[@]}"; do grep -Fxq " $scheme" artifacts/ios/schemes.log From 2c8e0feb27973240b8b22aecb651d2df7622bbd6 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 19:14:08 +0800 Subject: [PATCH 05/10] test: make image and text input XCTest reliable --- .../ios/OneKeyImageRequestContext.swift | 19 +++++++++++++++++++ .../ios/tests/OneKeyAvatarImageTests.swift | 9 ++++----- .../OneKeyTextInputPasteObserverTests.mm | 1 + 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/native-views/react-native-image/ios/OneKeyImageRequestContext.swift b/native-views/react-native-image/ios/OneKeyImageRequestContext.swift index 5fb9599aa..6142bb715 100644 --- a/native-views/react-native-image/ios/OneKeyImageRequestContext.swift +++ b/native-views/react-native-image/ios/OneKeyImageRequestContext.swift @@ -785,6 +785,25 @@ enum OneKeyImageRequestContext { return context } + #if DEBUG + // The separate pod test module cannot import make's full signature. + static func testContext( + headersJson: String?, + thumbnailPixelSize: CGSize?, + manager: SDWebImageManager, + url: URL? + ) -> [SDWebImageContextOption: Any] { + make( + headersJson: headersJson, + cachePolicy: .memoryDisk, + thumbnailPixelSize: thumbnailPixelSize, + safetyTracker: nil, + manager: manager, + url: url + ) + } + #endif + static func transportGuards( tracker: OneKeyImageSafetyTracker ) -> [SDWebImageContextOption: Any] { diff --git a/native-views/react-native-image/ios/tests/OneKeyAvatarImageTests.swift b/native-views/react-native-image/ios/tests/OneKeyAvatarImageTests.swift index 9c93474d0..8e0759de4 100644 --- a/native-views/react-native-image/ios/tests/OneKeyAvatarImageTests.swift +++ b/native-views/react-native-image/ios/tests/OneKeyAvatarImageTests.swift @@ -11,9 +11,8 @@ final class OneKeyAvatarImageTests: XCTestCase { let first = OneKeyImagePipeline.makeIsolatedManager() let second = OneKeyImagePipeline.makeIsolatedManager() func context(_ manager: SDWebImageManager, headers: String) -> [SDWebImageContextOption: Any] { - OneKeyImageRequestContext.make(headersJson: headers, cachePolicy: .memoryDisk, - thumbnailPixelSize: CGSize(width: 96, height: 96), safetyTracker: nil, - manager: manager, url: url) + OneKeyImageRequestContext.testContext(headersJson: headers, + thumbnailPixelSize: CGSize(width: 96, height: 96), manager: manager, url: url) } let a = context(first, headers: "{\"X-Test\":\"a\"}") let b = context(second, headers: "{\"X-Test\":\"b\"}") @@ -26,8 +25,8 @@ final class OneKeyAvatarImageTests: XCTestCase { let firstFilter = try XCTUnwrap(a[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) let secondFilter = try XCTUnwrap(b[.cacheKeyFilter] as? SDWebImageCacheKeyFilter) XCTAssertEqual(firstFilter.cacheKey(for: url), secondFilter.cacheKey(for: url)) - let remote = OneKeyImageRequestContext.make(headersJson: nil, cachePolicy: .memoryDisk, - thumbnailPixelSize: nil, safetyTracker: nil, manager: first, + let remote = OneKeyImageRequestContext.testContext(headersJson: nil, + thumbnailPixelSize: nil, manager: first, url: URL(string: "https://example.com/image.png")) XCTAssertNil(remote[.imageLoader]) XCTAssertNil(remote[.imageCache]) diff --git a/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm index 96c880f90..3068e99a5 100644 --- a/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm +++ b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm @@ -64,6 +64,7 @@ - (void)assertImagePasteAvailableWhileRefreshingAfterNotification:(NSNotificatio [UIColor.blackColor setFill]; UIRectFill(CGRectMake(0, 0, 1, 1)); }]; + [NSNotificationCenter.defaultCenter postNotificationName:UIPasteboardChangedNotification object:pasteboard]; XCTAssertEqual(dispatch_semaphore_wait(OneKeyTestReadStarted, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC)), 0L); [NSNotificationCenter.defaultCenter postNotificationName:name object:field]; From a64d02b2110ae3dd7ea7d7626a02bdb423a5caaa Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 19:29:42 +0800 Subject: [PATCH 06/10] test: intercept actual pasteboard class in iOS XCTest --- .github/workflows/native-tests.yml | 2 +- .../ios/tests/OneKeyTextInputPasteObserverTests.mm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index b593d43f4..b8c8e2a97 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -211,7 +211,7 @@ jobs: set -euo pipefail mkdir -p artifacts/ios workspace=example/react-native/ios/example.xcworkspace - schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests OneKeyImage-Unit-Tests) + schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyImage-Unit-Tests OneKeyTextInput-Unit-Tests) xcodebuild -list -workspace "$workspace" > artifacts/ios/schemes.log for scheme in "${schemes[@]}"; do grep -Fxq " $scheme" artifacts/ios/schemes.log diff --git a/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm index 3068e99a5..1aabc8466 100644 --- a/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm +++ b/native-views/react-native-text-input/ios/tests/OneKeyTextInputPasteObserverTests.mm @@ -54,7 +54,7 @@ - (void)assertImagePasteAvailableWhileRefreshingAfterNotification:(NSNotificatio } static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - Method original = class_getInstanceMethod(UIPasteboard.class, @selector(hasImages)); + Method original = class_getInstanceMethod(object_getClass(pasteboard), @selector(hasImages)); Method replacement = class_getInstanceMethod(UIPasteboard.class, @selector(onekey_test_hasImages)); method_exchangeImplementations(original, replacement); }); From 94d24de771a7820fa28f9e4a4e9949b1e1fcc8e5 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 19:43:39 +0800 Subject: [PATCH 07/10] ci: validate text input before image contract decision --- .github/workflows/native-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index b8c8e2a97..b593d43f4 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -211,7 +211,7 @@ jobs: set -euo pipefail mkdir -p artifacts/ios workspace=example/react-native/ios/example.xcworkspace - schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyImage-Unit-Tests OneKeyTextInput-Unit-Tests) + schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests OneKeyImage-Unit-Tests) xcodebuild -list -workspace "$workspace" > artifacts/ios/schemes.log for scheme in "${schemes[@]}"; do grep -Fxq " $scheme" artifacts/ios/schemes.log From 86bd9877ad9e6e0ad08c1bdbc8697a874549c566 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Thu, 24 Sep 2026 21:05:44 +0800 Subject: [PATCH 08/10] fix(image): retain round mask during nil prop reset --- native-views/react-native-image/docs/SPEC.md | 5 +++++ native-views/react-native-image/ios/OneKeyImage.swift | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/native-views/react-native-image/docs/SPEC.md b/native-views/react-native-image/docs/SPEC.md index 879ecb174..0f0e0e34f 100644 --- a/native-views/react-native-image/docs/SPEC.md +++ b/native-views/react-native-image/docs/SPEC.md @@ -74,6 +74,11 @@ own image bytes and a stale hint is removed after a memory miss. | `loadingStrategy` | `static` by default; `skeleton` and `none` are supported. A React `placeholder` disables the native loading strategy for that instance. | | `placeholderColor` | Optional theme-aware native color for loading and terminal native states. | +On iOS, an explicit `round` value updates the shape while a source is active. +A native `nil` during a Fabric prop reset retains the last committed shape until +an explicit value arrives or a new source is committed after reuse. The React +wrapper supplies `false` when the prop is omitted. + React `placeholder` and `fallback` are optional overlay content. Their container clips to the same rounded bounds as the native image and does not receive pointer events. diff --git a/native-views/react-native-image/ios/OneKeyImage.swift b/native-views/react-native-image/ios/OneKeyImage.swift index 6cd6e043c..0f2f2444d 100644 --- a/native-views/react-native-image/ios/OneKeyImage.swift +++ b/native-views/react-native-image/ios/OneKeyImage.swift @@ -210,8 +210,8 @@ final class HybridOneKeyImage: HybridOneKeyImageSpec, RecyclableView { // Fabric can reset `round` before clearing `sourceUri` while removing a view. // Apply the shape once per committed prop batch so the outgoing frame keeps // its clipping, while mounted and recycled views still receive the new value. - if let sourceUri, !sourceUri.isEmpty { - hostView.round = round == true + if let sourceUri, !sourceUri.isEmpty, let round { + hostView.round = round } scheduleLoad() } From ea07b7fa52e9d66398a73c447e810613b73a81f6 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 25 Sep 2026 08:10:53 +0800 Subject: [PATCH 09/10] test: cover native list lifecycle and bundle crypto on iOS --- .github/workflows/native-tests.yml | 4 +- example/react-native/ios/Podfile | 2 + example/react-native/ios/Podfile.lock | 58 +++++++++- .../ReactNativeBundleCrypto.podspec | 5 + .../ios/tests/BundleCryptoCoreTests.swift | 105 ++++++++++++++++++ .../ios/tests/NativeListLifecycleTests.swift | 90 +++++++++++++++ .../react-native-native-list.podspec | 6 + 7 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift create mode 100644 native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index b593d43f4..c8475166f 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -205,13 +205,13 @@ jobs: end puts 'Podfile.lock matches: only the Hermes checksum and Specs CDN URL may differ' RUBY - - name: Run four pod XCTest suites on iOS Simulator + - name: Run six pod XCTest suites on iOS Simulator shell: bash run: | set -euo pipefail mkdir -p artifacts/ios workspace=example/react-native/ios/example.xcworkspace - schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests OneKeyImage-Unit-Tests) + schemes=(NativeSheet-Unit-Tests react-native-pager-view-Unit-Tests OneKeyTextInput-Unit-Tests OneKeyImage-Unit-Tests react-native-native-list-Unit-Tests ReactNativeBundleCrypto-Unit-Tests) xcodebuild -list -workspace "$workspace" > artifacts/ios/schemes.log for scheme in "${schemes[@]}"; do grep -Fxq " $scheme" artifacts/ios/schemes.log diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index f6193c957..363afe81c 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -46,6 +46,8 @@ target 'example' do pod 'NativeSheet', :path => '../../../node_modules/@onekeyfe/react-native-native-sheet', :testspecs => ['Tests'] pod 'react-native-pager-view', :path => '../../../node_modules/@onekeyfe/react-native-pager-view', :testspecs => ['Tests'] pod 'OneKeyTextInput', :path => '../../../node_modules/@onekeyfe/react-native-text-input', :testspecs => ['Tests'] + pod 'react-native-native-list', :path => '../../../node_modules/@onekeyfe/react-native-native-list', :testspecs => ['Tests'] + pod 'ReactNativeBundleCrypto', :path => '../../../node_modules/@onekeyfe/react-native-bundle-crypto', :testspecs => ['Tests'] # Copy optional offline TradingView chart material into the app bundle as # `tradingview-assets/`, preserving the diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index cf85f90d5..0018b6316 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1924,6 +1924,31 @@ PODS: - ReactCommon/turbomodule/core - ReactNativeDependencies - Yoga + - react-native-native-list/Tests (3.0.156): + - hermes-engine + - NitroModules + - OneKeyImage (= 3.0.156) + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - Yoga - react-native-pager-view (3.0.156): - hermes-engine - RCTRequired @@ -2528,6 +2553,31 @@ PODS: - ReactNativeDependencies - ReactNativeNativeLogger - Yoga + - ReactNativeBundleCrypto/Tests (3.0.156): + - hermes-engine + - NitroModules + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-Core-prebuilt + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - ReactNativeDependencies + - ReactNativeNativeLogger + - Yoga - ReactNativeBundleUpdate (3.0.156): - hermes-engine - MMKV (= 2.4.0) @@ -3094,6 +3144,7 @@ DEPENDENCIES: - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-mutationobservernativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/mutationobserver`) - "react-native-native-list (from `../../../node_modules/@onekeyfe/react-native-native-list`)" + - "react-native-native-list/Tests (from `../../../node_modules/@onekeyfe/react-native-native-list`)" - "react-native-pager-view (from `../../../node_modules/@onekeyfe/react-native-pager-view`)" - "react-native-pager-view/Tests (from `../../../node_modules/@onekeyfe/react-native-pager-view`)" - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) @@ -3134,6 +3185,7 @@ DEPENDENCIES: - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - "ReactNativeAppUpdate (from `../../../node_modules/@onekeyfe/react-native-app-update`)" - "ReactNativeBundleCrypto (from `../../../node_modules/@onekeyfe/react-native-bundle-crypto`)" + - "ReactNativeBundleCrypto/Tests (from `../../../node_modules/@onekeyfe/react-native-bundle-crypto`)" - "ReactNativeBundleUpdate (from `../../../node_modules/@onekeyfe/react-native-bundle-update`)" - "ReactNativeCheckBiometricAuthChanged (from `../../../node_modules/@onekeyfe/react-native-check-biometric-auth-changed`)" - ReactNativeDependencies (from `../../../node_modules/react-native/third-party-podspecs/ReactNativeDependencies.podspec`) @@ -3474,7 +3526,7 @@ SPEC CHECKSUMS: React-Mapbuffer: 44dd007f917e2396afa0e70bbd70273237d93fde React-microtasksnativemodule: f413ee411341fb3c34d20e85e9f6f524921fab15 React-mutationobservernativemodule: 58f6d2adf7d98e72b241608dfa2ddce414d2a4e7 - react-native-native-list: ee4f08215477be36d96d80a3473ab7567e1a3994 + react-native-native-list: c586598e9a4902586b091b060d8b594013dc442a react-native-pager-view: c41389cfc805ce60b5980924a480f982ae261e7f react-native-safe-area-context: 91a90d98c310adcc90a511e5aeb6046d7c19d885 react-native-tab-view: b62691e2b0492c55dc436cf2f971c27f4a0165cc @@ -3513,7 +3565,7 @@ SPEC CHECKSUMS: ReactCodegen: 01cbf7a08c30e8b71ad71489bb56b5bed195fa5e ReactCommon: 9002f006f571256348994183f7d4387aa7cf84e8 ReactNativeAppUpdate: 4880dff75e3bf33305882760cccaec0bccff2d21 - ReactNativeBundleCrypto: aa732a62ba36fffbd8906383db5b4d01a15d039e + ReactNativeBundleCrypto: b156193d3236705100440499df94fbcdf1740e6e ReactNativeBundleUpdate: f07c516f08f30dfecc7c36c63495ca984642442c ReactNativeCheckBiometricAuthChanged: 6b9c67db94c2a3b9f8454710d7a1eaf4e3b3e2a0 ReactNativeDependencies: 2bd6854ade79bf1b60586d1ab7813a389df1b6bf @@ -3540,6 +3592,6 @@ SPEC CHECKSUMS: TcpSocket: 2725be5301911fb4a3bcc4f85915dcacfd0ccfb1 Yoga: 9891cfb1c680f64de9c41b09e7453dea33454d2b -PODFILE CHECKSUM: 225cc4ef7243271a31edb13b0c0b95d857fdcd05 +PODFILE CHECKSUM: d163efd24ff4c7dc7da7c6c0f1023634b164196e COCOAPODS: 1.16.2 diff --git a/native-modules/react-native-bundle-crypto/ReactNativeBundleCrypto.podspec b/native-modules/react-native-bundle-crypto/ReactNativeBundleCrypto.podspec index 964955886..0d9cd8113 100644 --- a/native-modules/react-native-bundle-crypto/ReactNativeBundleCrypto.podspec +++ b/native-modules/react-native-bundle-crypto/ReactNativeBundleCrypto.podspec @@ -18,6 +18,7 @@ Pod::Spec.new do |s| "ios/**/*.{m,mm}", "cpp/**/*.{hpp,cpp}", ] + s.exclude_files = "ios/tests/**/*" # Vendored Gopenpgp framework: GPG cleartext/detached signature verification. # This is the single in-tree copy used by bundle-crypto and by modules that @@ -32,4 +33,8 @@ Pod::Spec.new do |s| add_nitrogen_files(s) install_modules_dependencies(s) + + s.test_spec "Tests" do |test_spec| + test_spec.source_files = "ios/tests/**/*.swift" + end end diff --git a/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift b/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift new file mode 100644 index 000000000..828d12ca7 --- /dev/null +++ b/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift @@ -0,0 +1,105 @@ +import Foundation +import ReactNativeBundleCrypto +import XCTest + +final class BundleCryptoCoreTests: XCTestCase { + private let expectedSignedHash = "2ada9c871104fc40649fa3de67a7d8e33faadc18e9abd587e8bb85be0a003eba" + + // Existing release-key test vector, also used by BundleUpdate.testVerification. + private let signedMetadata = """ + -----BEGIN PGP SIGNED MESSAGE----- + Hash: SHA256 + + { + "fileName": "metadata.json", + "sha256": "2ada9c871104fc40649fa3de67a7d8e33faadc18e9abd587e8bb85be0a003eba", + "size": 158590, + "generatedAt": "2025-09-19T07:49:13.000Z" + } + -----BEGIN PGP SIGNATURE----- + + iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmjNJ1IOHGRldkBvbmVr + ZXkuc28ACgkQs2mmepC/OHs6Rw/9FKHl5aNsE7V0IsFf/l+h16BYKFwVsL69alMk + CFLna8oUn0+tyECF6wKBKw5pHo5YR27o2pJfYbAER6dygDF6WTZ1lZdf5QcBMjGA + LCeXC0hzUBzSSOH4bKBTa3fHp//HdSV1F2OnkymbXqYN7WXvuQPLZ0nV6aU88hCk + HgFifcvkXAnWKoosUtj0Bban/YBRyvmQ5C2akxUPEkr4Yck1QXwzJeNRd7wMXHjH + JFK6lJcuABiB8wpJDXJkFzKs29pvHIK2B2vdOjU2rQzKOUwaKHofDi5C4+JitT2b + 2pSeYP3PAxXYw6XDOmKTOiC7fPnfLjtcPjNYNFCezVKZT6LKvZW9obnW8Q9LNJ4W + okMPgHObkabv3OqUaTA9QNVfI/X9nvggzlPnaKDUrDWTf7n3vlrdexugkLtV/tJA + uguPlI5hY7Ue5OW7ckWP46hfmq1+UaIdeUY7dEO+rPZDz6KcArpaRwBiLPBhneIr + /X3KuMzS272YbPbavgCZGN9xJR5kZsEQE5HhPCbr6Nf0qDnh+X8mg0tAB/U6F+ZE + o90sJL1ssIaYvST+VWVaGRr4V5nMDcgHzWSF9Q/wm22zxe4alDaBdvOlUseW0iaM + n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ + 91Js0rQ= + =A/Ii + -----END PGP SIGNATURE----- + """ + + func testSignedMetadataAcceptsReleaseKeyAndRejectsTampering() { + let verified = BundleCryptoCore.verifyGpgCleartext(signedMetadata) + XCTAssertTrue(verified.valid) + XCTAssertEqual(verified.sha256, expectedSignedHash) + XCTAssertNil(verified.reason) + + let tampered = signedMetadata.replacingOccurrences(of: "158590", with: "158591") + let rejected = BundleCryptoCore.verifyGpgCleartext(tampered) + XCTAssertFalse(rejected.valid) + XCTAssertNil(rejected.sha256) + XCTAssertNotNil(rejected.reason) + + let unsigned = BundleCryptoCore.verifyGpgCleartext("{\"sha256\":\"\(expectedSignedHash)\"}") + XCTAssertFalse(unsigned.valid) + XCTAssertNil(unsigned.sha256) + XCTAssertEqual(unsigned.reason, "NOT_PGP_SIGNED_MESSAGE") + } + + func testDirectoryHashesRejectChangedMissingAndUnlistedFiles() throws { + try withTemporaryDirectory { root in + let bundle = root.appendingPathComponent("bundle", isDirectory: true) + let nested = bundle.appendingPathComponent("nested/bundle", isDirectory: true) + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + let file = nested.appendingPathComponent("index.js") + try "abc".write(to: file, atomically: true, encoding: .utf8) + try "ignored".write(to: bundle.appendingPathComponent("metadata.json"), atomically: true, encoding: .utf8) + + let hashes = try BundleCryptoCore.hashDir(bundle.path) + XCTAssertEqual(hashes.count, 1) + XCTAssertEqual(hashes.first?.relativePath, "nested/bundle/index.js") + XCTAssertEqual(hashes.first?.sha256, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad") + XCTAssertTrue(BundleCryptoCore.verifyDirAgainstHashes(dirPath: bundle.path, entries: hashes)) + + try "changed".write(to: file, atomically: true, encoding: .utf8) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes(dirPath: bundle.path, entries: hashes)) + try FileManager.default.removeItem(at: file) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes(dirPath: bundle.path, entries: hashes)) + + try "abc".write(to: file, atomically: true, encoding: .utf8) + try "unlisted".write(to: bundle.appendingPathComponent("evil-metadata.json"), atomically: true, encoding: .utf8) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes(dirPath: bundle.path, entries: hashes)) + } + } + + func testExtractedPathSafetyRejectsSymlinks() throws { + try withTemporaryDirectory { root in + let destination = root.appendingPathComponent("bundle", isDirectory: true) + let outside = root.appendingPathComponent("outside.txt") + try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true) + try "outside".write(to: outside, atomically: true, encoding: .utf8) + try "inside".write(to: destination.appendingPathComponent("index.js"), atomically: true, encoding: .utf8) + XCTAssertTrue(BundleCryptoCore.validateExtractedPathSafety(destination.path)) + + try FileManager.default.createSymbolicLink( + at: destination.appendingPathComponent("escape"), + withDestinationURL: outside + ) + XCTAssertFalse(BundleCryptoCore.validateExtractedPathSafety(destination.path)) + } + } + + private func withTemporaryDirectory(_ body: (URL) throws -> Void) throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + try body(root) + } +} diff --git a/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift new file mode 100644 index 000000000..7e3adcdbf --- /dev/null +++ b/native-views/react-native-native-list/ios/tests/NativeListLifecycleTests.swift @@ -0,0 +1,90 @@ +import UIKit +import XCTest +@testable import NativeListModule + +@MainActor +final class NativeListLifecycleTests: XCTestCase { + func testRebindingAndReuseInvalidateThePreviousActionEpoch() throws { + let cell = NativeListActionCell(frame: CGRect(x: 0, y: 0, width: 320, height: 60)) + let first = try NativeListItem(data: ["key": "row", "type": "action", "title": "First"]) + let changed = try NativeListItem(data: ["key": "row", "type": "action", "title": "Changed"]) + var invalidatedEpochs: [Int] = [] + cell.onBindingInvalidated = { _, epoch in invalidatedEpochs.append(epoch) } + + func bind(_ item: NativeListItem) { + cell.bind(item: item, theme: nil, layout: "linear", selected: false, + checkboxState: { _, _, fallback in fallback }) + } + + bind(first) + let originalEpoch = cell.rowActionOrigin().bindingEpoch + bind(first) + XCTAssertEqual(cell.bindingEpoch, originalEpoch, "Identical content should retain its action origin") + XCTAssertTrue(invalidatedEpochs.isEmpty) + + bind(changed) + XCTAssertEqual(invalidatedEpochs, [originalEpoch]) + XCTAssertNotEqual(cell.rowActionOrigin().bindingEpoch, originalEpoch) + + let changedEpoch = cell.bindingEpoch + cell.prepareForReuse() + XCTAssertEqual(invalidatedEpochs, [originalEpoch, changedEpoch]) + XCTAssertNotEqual(cell.bindingEpoch, changedEpoch) + } + + func testFixedFooterReplacesRendererAndKeepsActionRouting() throws { + let view = NativeListView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) + var actions: [[String: Any]] = [] + view.onRowAction = { payload in + let data = Data(payload.utf8) + if let action = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] { + actions.append(action) + } + } + + view.applySnapshotJson(try snapshot(footer: [ + "key": "footer", "type": "action", "title": "Continue", "actionKey": "continue", + ])) + let first = try XCTUnwrap(footerCell(in: view)) + XCTAssertTrue(first is NativeListActionCell) + XCTAssertFalse(first.isHidden) + XCTAssertEqual(first.accessibilityLabel, "Continue") + let gestureCount = first.gestureRecognizers?.count + + view.applySnapshotJson(try snapshot(footer: [ + "key": "footer", "type": "system", "variant": "retry", "title": "Retry", + "actionKey": "retry", + ])) + let second = try XCTUnwrap(footerCell(in: view)) + XCTAssertTrue(second is NativeListSystemCell) + XCTAssertFalse(second === first) + XCTAssertNil(first.superview) + XCTAssertFalse(second.isHidden) + XCTAssertEqual(second.accessibilityLabel, "Retry") + XCTAssertEqual(second.gestureRecognizers?.count, gestureCount) + + second.onAction?(try NativeListItem(data: [ + "key": "footer", "type": "system", "variant": "retry", "title": "Retry", + "actionKey": "retry", + ]), "retry", nil, nil) + XCTAssertEqual(actions.last?["actionKey"] as? String, "retry") + + view.applySnapshotJson(try snapshot(footer: nil)) + XCTAssertTrue(second.isHidden) + } + + private func snapshot(footer: [String: Any]?) throws -> String { + var value: [String: Any] = [ + "schemaVersion": 1, "generation": 1, "layout": ["kind": "linear"], "rows": [], + ] + if let footer { value["fixedFooter"] = footer } + let data = try JSONSerialization.data(withJSONObject: value, options: [.sortedKeys]) + return String(decoding: data, as: UTF8.self) + } + + private func footerCell(in view: NativeListView) -> NativeListRowHost? { + view.subviews.lazy.compactMap { container in + container.subviews.compactMap { $0 as? NativeListRowHost }.first + }.first + } +} diff --git a/native-views/react-native-native-list/react-native-native-list.podspec b/native-views/react-native-native-list/react-native-native-list.podspec index 1e43b18d1..65d268f01 100644 --- a/native-views/react-native-native-list/react-native-native-list.podspec +++ b/native-views/react-native-native-list/react-native-native-list.podspec @@ -13,6 +13,7 @@ Pod::Spec.new do |s| s.module_name = "NativeListModule" s.source = { :git => "https://github.com/OneKeyHQ/app-modules.git", :tag => "#{s.version}" } s.source_files = ["ios/**/*.{swift,h,m,mm,cpp}"] + s.exclude_files = "ios/tests/**/*" s.resource_bundles = { "NativeListResources" => [ "common/fonts/*.ttf", @@ -32,6 +33,11 @@ Pod::Spec.new do |s| s.dependency "React-jsi" s.dependency "React-callinvoker" + s.test_spec "Tests" do |test_spec| + test_spec.source_files = "ios/tests/**/*.swift" + test_spec.pod_target_xcconfig = { "PRODUCT_MODULE_NAME" => "NativeListModuleTests" } + end + load "nitrogen/generated/ios/NativeListModule+autolinking.rb" add_nitrogen_files(s) install_modules_dependencies(s) From d82e87a7868692cd21ad697edcfc43d3d2f1c057 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 25 Sep 2026 12:05:09 +0800 Subject: [PATCH 10/10] test: cover Android native paths and reject escaping bundle hashes --- .github/workflows/native-tests.yml | 8 +- .../react-native-bundle-crypto/README.md | 2 + .../android/build.gradle | 2 + .../BundleCryptoCore.kt | 8 +- .../margelo/nitro/nativelogger/OneKeyLog.kt | 10 ++ .../BundleCryptoCoreTest.kt | 135 ++++++++++++++++++ .../react-native-bundle-crypto/docs/SPEC.md | 30 ++++ .../ios/BundleCryptoCore.swift | 10 +- .../ios/tests/BundleCryptoCoreTests.swift | 31 ++++ .../android/build.gradle | 6 + .../com/textinput/TextInputViewPasteTest.kt | 116 +++++++++++++++ .../react-native-text-input/docs/SPEC.md | 12 +- 12 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/nativelogger/OneKeyLog.kt create mode 100644 native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCoreTest.kt create mode 100644 native-modules/react-native-bundle-crypto/docs/SPEC.md create mode 100644 native-views/react-native-text-input/android/src/test/java/com/textinput/TextInputViewPasteTest.kt diff --git a/.github/workflows/native-tests.yml b/.github/workflows/native-tests.yml index c8475166f..ad76f77ce 100644 --- a/.github/workflows/native-tests.yml +++ b/.github/workflows/native-tests.yml @@ -33,17 +33,19 @@ jobs: # shellcheck disable=SC2016 find native-modules native-views -mindepth 2 -maxdepth 2 -name nitro.json -print0 | xargs -0 -n1 -P4 bash -c 'cd "$(dirname "$0")" && yarn nitrogen > /dev/null' - - name: Run seven Android unit test suites + - name: Run nine Android unit test suites working-directory: example/react-native/android run: | ./gradlew --continue --no-daemon --max-workers=2 --console=plain \ :onekeyfe_react-native-app-update:testDebugUnitTest \ + :onekeyfe_react-native-bundle-crypto:testDebugUnitTest \ :onekeyfe_react-native-network-throttle:testDebugUnitTest \ :onekeyfe_react-native-range-downloader:testDebugUnitTest \ :onekeyfe_react-native-sni-connect:testDebugUnitTest \ :onekeyfe_react-native-auto-size-input:testDebugUnitTest \ :onekeyfe_react-native-image:testDebugUnitTest \ - :onekeyfe_react-native-native-list:testDebugUnitTest + :onekeyfe_react-native-native-list:testDebugUnitTest \ + :onekeyfe_react-native-text-input:testDebugUnitTest - name: Verify every Android suite executed tests if: always() run: | @@ -53,12 +55,14 @@ jobs: packages = { 'native-modules/react-native-app-update', + 'native-modules/react-native-bundle-crypto', 'native-modules/react-native-network-throttle', 'native-modules/react-native-range-downloader', 'native-modules/react-native-sni-connect', 'native-views/react-native-auto-size-input', 'native-views/react-native-image', 'native-views/react-native-native-list', + 'native-views/react-native-text-input', } total = 0 for package in sorted(packages): diff --git a/native-modules/react-native-bundle-crypto/README.md b/native-modules/react-native-bundle-crypto/README.md index e7dc52dd0..000341d3e 100644 --- a/native-modules/react-native-bundle-crypto/README.md +++ b/native-modules/react-native-bundle-crypto/README.md @@ -2,6 +2,8 @@ react-native-bundle-crypto +[Native behavior specification](docs/SPEC.md) + ## Installation ```sh diff --git a/native-modules/react-native-bundle-crypto/android/build.gradle b/native-modules/react-native-bundle-crypto/android/build.gradle index cac9ba450..e56bb9236 100644 --- a/native-modules/react-native-bundle-crypto/android/build.gradle +++ b/native-modules/react-native-bundle-crypto/android/build.gradle @@ -133,4 +133,6 @@ dependencies { // react-native-app-update (the modules this security logic was migrated from). implementation "org.bouncycastle:bcpg-jdk15to18:1.78.1" implementation "org.bouncycastle:bcprov-jdk15to18:1.78.1" + + testImplementation "junit:junit:4.13.2" } diff --git a/native-modules/react-native-bundle-crypto/android/src/main/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCore.kt b/native-modules/react-native-bundle-crypto/android/src/main/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCore.kt index 922e1f177..a7b2c30e5 100644 --- a/native-modules/react-native-bundle-crypto/android/src/main/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCore.kt +++ b/native-modules/react-native-bundle-crypto/android/src/main/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCore.kt @@ -391,8 +391,14 @@ object BundleCryptoCore { return false } val jsBundleDir = dir.absolutePath + "/" + val resolvedDir = try { dir.canonicalFile.toPath() } catch (e: Exception) { return false } val expected = HashMap() - for (entry in entries) expected[entry.relativePath] = entry.sha256 + for (entry in entries) { + if (entry.relativePath.isEmpty() || File(entry.relativePath).isAbsolute) return false + val resolvedFile = try { File(dir, entry.relativePath).canonicalFile.toPath() } catch (e: Exception) { return false } + if (resolvedFile == resolvedDir || !resolvedFile.startsWith(resolvedDir)) return false + expected[entry.relativePath] = entry.sha256 + } if (!validateFilesRecursive(dir, expected, jsBundleDir)) return false diff --git a/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/nativelogger/OneKeyLog.kt b/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/nativelogger/OneKeyLog.kt new file mode 100644 index 000000000..1cbdc8d6a --- /dev/null +++ b/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/nativelogger/OneKeyLog.kt @@ -0,0 +1,10 @@ +package com.margelo.nitro.nativelogger + +// Host-JVM test fixture: keep the production crypto core and BouncyCastle real +// while avoiding the production logger's Android/Nitro JNI initialization. +object OneKeyLog { + @JvmStatic + fun error(tag: String, message: String) = Unit + @JvmStatic + fun info(tag: String, message: String) = Unit +} diff --git a/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCoreTest.kt b/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCoreTest.kt new file mode 100644 index 000000000..d872f2475 --- /dev/null +++ b/native-modules/react-native-bundle-crypto/android/src/test/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCoreTest.kt @@ -0,0 +1,135 @@ +package com.margelo.nitro.reactnativebundlecrypto + +import java.io.File +import java.nio.file.Files +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class BundleCryptoCoreTest { + @get:Rule val temporaryFolder = TemporaryFolder() + + private val expectedSignedHash = "2ada9c871104fc40649fa3de67a7d8e33faadc18e9abd587e8bb85be0a003eba" + + // Existing release-key test vector, also used by BundleUpdate.testVerification. + private val signedMetadata = """-----BEGIN PGP SIGNED MESSAGE----- +Hash: SHA256 + +{ + "fileName": "metadata.json", + "sha256": "2ada9c871104fc40649fa3de67a7d8e33faadc18e9abd587e8bb85be0a003eba", + "size": 158590, + "generatedAt": "2025-09-19T07:49:13.000Z" +} +-----BEGIN PGP SIGNATURE----- + +iQJCBAEBCAAsFiEE62iuVE8f3YzSZGJPs2mmepC/OHsFAmjNJ1IOHGRldkBvbmVr +ZXkuc28ACgkQs2mmepC/OHs6Rw/9FKHl5aNsE7V0IsFf/l+h16BYKFwVsL69alMk +CFLna8oUn0+tyECF6wKBKw5pHo5YR27o2pJfYbAER6dygDF6WTZ1lZdf5QcBMjGA +LCeXC0hzUBzSSOH4bKBTa3fHp//HdSV1F2OnkymbXqYN7WXvuQPLZ0nV6aU88hCk +HgFifcvkXAnWKoosUtj0Bban/YBRyvmQ5C2akxUPEkr4Yck1QXwzJeNRd7wMXHjH +JFK6lJcuABiB8wpJDXJkFzKs29pvHIK2B2vdOjU2rQzKOUwaKHofDi5C4+JitT2b +2pSeYP3PAxXYw6XDOmKTOiC7fPnfLjtcPjNYNFCezVKZT6LKvZW9obnW8Q9LNJ4W +okMPgHObkabv3OqUaTA9QNVfI/X9nvggzlPnaKDUrDWTf7n3vlrdexugkLtV/tJA +uguPlI5hY7Ue5OW7ckWP46hfmq1+UaIdeUY7dEO+rPZDz6KcArpaRwBiLPBhneIr +/X3KuMzS272YbPbavgCZGN9xJR5kZsEQE5HhPCbr6Nf0qDnh+X8mg0tAB/U6F+ZE +o90sJL1ssIaYvST+VWVaGRr4V5nMDcgHzWSF9Q/wm22zxe4alDaBdvOlUseW0iaM +n2DMz6gqk326W6SFynYtvuiXo7wG4Cmn3SuIU8xfv9rJqunpZGYchMd7nZektmEJ +91Js0rQ= +=A/Ii +-----END PGP SIGNATURE-----""" + + @Test + fun signedMetadataRejectsChangedBodyAndUnsignedInput() { + val tampered = signedMetadata.replace("158590", "158591") + val rejected = BundleCryptoCore.verifyGpgCleartext(tampered) + assertFalse(rejected.valid) + assertNull(rejected.sha256) + assertEquals("SIGNATURE_INVALID", rejected.reason) + + val unsigned = BundleCryptoCore.verifyGpgCleartext("{\"sha256\":\"$expectedSignedHash\"}") + assertFalse(unsigned.valid) + assertNull(unsigned.sha256) + assertEquals("NOT_PGP_SIGNED_MESSAGE", unsigned.reason) + } + + @Test + fun validSignatureWithWrongAscBodyIsRejectedAfterVerification() { + val result = BundleCryptoCore.verifyDetachedAsc(signedMetadata) + assertFalse(result.valid) + assertNull(result.sha256) + assertEquals("SHA256_TOKEN_INVALID", result.reason) + } + + @Test + fun directoryHashesRejectChangedMissingAndUnlistedFiles() { + val bundle = temporaryFolder.newFolder("bundle") + val nested = File(bundle, "nested/bundle").apply { mkdirs() } + val file = File(nested, "index.js").apply { writeText("abc") } + File(bundle, "metadata.json").writeText("ignored") + + val hashes = BundleCryptoCore.hashDir(bundle.absolutePath) + assertEquals(1, hashes.size) + assertEquals("nested/bundle/index.js", hashes.single().relativePath) + assertEquals("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", hashes.single().sha256) + assertTrue(BundleCryptoCore.verifyDirAgainstHashes(bundle.absolutePath, hashes)) + + file.writeText("changed") + assertFalse(BundleCryptoCore.verifyDirAgainstHashes(bundle.absolutePath, hashes)) + file.delete() + assertFalse(BundleCryptoCore.verifyDirAgainstHashes(bundle.absolutePath, hashes)) + + file.writeText("abc") + File(bundle, "evil-metadata.json").writeText("unlisted") + assertFalse(BundleCryptoCore.verifyDirAgainstHashes(bundle.absolutePath, hashes)) + } + + @Test + fun directoryHashesRejectExpectedPathsOutsideBundle() { + val root = temporaryFolder.newFolder("root") + val bundle = File(root, "bundle").apply { mkdirs() } + val outside = File(root, "outside.txt").apply { writeText("outside") } + val wrongHash = "0".repeat(64) + + assertFalse( + BundleCryptoCore.verifyDirAgainstHashes( + bundle.absolutePath, + listOf(BundleCryptoCore.DirHash("../outside.txt", wrongHash)) + ) + ) + assertFalse( + BundleCryptoCore.verifyDirAgainstHashes( + bundle.absolutePath, + listOf(BundleCryptoCore.DirHash(outside.absolutePath, wrongHash)) + ) + ) + Files.createSymbolicLink(File(bundle, "link.txt").toPath(), outside.toPath()) + assertFalse( + BundleCryptoCore.verifyDirAgainstHashes( + bundle.absolutePath, + listOf(BundleCryptoCore.DirHash("link.txt", wrongHash)) + ) + ) + assertFalse( + BundleCryptoCore.verifyDirAgainstHashes( + bundle.absolutePath, + listOf(BundleCryptoCore.DirHash("", wrongHash)) + ) + ) + } + + @Test + fun extractedPathSafetyRejectsSymlinks() { + val bundle = temporaryFolder.newFolder("extracted") + val outside = temporaryFolder.newFile("outside.txt").apply { writeText("outside") } + File(bundle, "index.js").writeText("inside") + assertTrue(BundleCryptoCore.validateExtractedPathSafety(bundle.absolutePath)) + + Files.createSymbolicLink(File(bundle, "escape").toPath(), outside.toPath()) + assertFalse(BundleCryptoCore.validateExtractedPathSafety(bundle.absolutePath)) + } +} diff --git a/native-modules/react-native-bundle-crypto/docs/SPEC.md b/native-modules/react-native-bundle-crypto/docs/SPEC.md new file mode 100644 index 000000000..79a951552 --- /dev/null +++ b/native-modules/react-native-bundle-crypto/docs/SPEC.md @@ -0,0 +1,30 @@ +# Bundle Crypto Native Contract + +Status: Implemented. The native checks listed below provide focused runtime evidence only where noted. + +## Purpose and ownership + +This package verifies signed update metadata, computes file hashes, checks a bundle directory against expected hashes, and provides local file operations to other OneKey native modules. The caller owns download, signature-file acquisition, metadata parsing, and the decision to install an update. The embedded OneKey public key is native-only and is not returned to JavaScript. + +## Public API and data + +- `verifyGpgCleartext` verifies a PGP cleartext-signed JSON message and returns its `sha256` field only after successful signature verification. `verifyDetachedAsc` verifies a PGP cleartext-signed SHA256SUMS body and returns its first 64-character hex token. Verification failures return `valid: false` with a reason and no hash. +- `sha256OfFile` streams a file and returns either a lowercase SHA-256 hex digest or a failure reason. The exact failure-reason taxonomy differs by platform. +- `secureEqualHex` compares two UTF-8 strings byte by byte after checking equal length. +- `hashDir` returns relative paths and SHA-256 hashes for files under a directory. `verifyDirAgainstHashes` checks that every non-exempt on-disk file has a matching expected hash and that every expected file exists. Files whose exact basename is `metadata.json` or `.DS_Store` are exempt from hashing. +- `validateExtractedPathSafety` rejects symbolic links and paths resolving outside an extracted directory. `atomicWriteFile`, `safeRename`, and `listVersionDirs` perform local file operations; the caller owns when to invoke them. + +## Directory hash safety and failure + +Each `DirHashEntry.relativePath` passed to `verifyDirAgainstHashes` must name a file below `dirPath`. Empty paths, absolute paths, and paths that resolve to the root or outside it are invalid. Resolution includes `..` segments and symbolic links. Invalid entries, missing expected files, unlisted files, and hash mismatches return `false`. An outside path is never accepted merely because the named file exists. Both Android and iOS enforce this boundary before checking directory contents. + +## Lifecycle, platform behavior, and resource limits + +The verification and file operations are synchronous inside each native core; the Nitro wrappers return promises for the asynchronous API methods. No long-lived request state is retained by the core. File hashing streams input rather than loading entire files into memory. Android uses BouncyCastle for PGP verification and Java `MessageDigest`; iOS uses Gopenpgp and CommonCrypto. Both platforms use the same embedded public key and directory-path boundary. The caller must separately limit downloaded data and decide how to handle failed verification. + +## Conformance and acceptance + +- Android implementation: `android/src/main/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCore.kt`. JVM tests: `android/src/test/java/com/margelo/nitro/reactnativebundlecrypto/BundleCryptoCoreTest.kt`. +- iOS implementation: `ios/BundleCryptoCore.swift`. XCTest: `ios/tests/BundleCryptoCoreTests.swift`. +- Focused tests exercise the real native cores for signed-message rejection, directory hash matching and failure, invalid expected paths, and extracted symlink rejection. The iOS tests also cover a valid signed metadata vector. The Android JVM test does not yet verify extraction of a valid signed JSON body because its host runtime lacks a real `org.json.JSONObject` implementation. +- The focused tests do not prove Nitro bridge behavior, download/install integration, actual archive extraction, or physical-device filesystem behavior. Those remain integration and device acceptance cases. diff --git a/native-modules/react-native-bundle-crypto/ios/BundleCryptoCore.swift b/native-modules/react-native-bundle-crypto/ios/BundleCryptoCore.swift index 38b78b6a3..160c52822 100644 --- a/native-modules/react-native-bundle-crypto/ios/BundleCryptoCore.swift +++ b/native-modules/react-native-bundle-crypto/ios/BundleCryptoCore.swift @@ -411,9 +411,17 @@ public enum BundleCryptoCore { } // Normalize the directory prefix so relative paths match. let normalizedDir = (dirPath as NSString).hasSuffix("/") ? dirPath : dirPath + "/" + let resolvedDir = (dirPath as NSString).resolvingSymlinksInPath + let resolvedPrefix = resolvedDir == "/" ? "/" : resolvedDir + "/" var expected: [String: String] = [:] - for entry in entries { expected[entry.relativePath] = entry.sha256 } + for entry in entries { + guard !entry.relativePath.isEmpty, + !(entry.relativePath as NSString).isAbsolutePath else { return false } + let resolvedFile = ((normalizedDir + entry.relativePath) as NSString).resolvingSymlinksInPath + guard resolvedFile.hasPrefix(resolvedPrefix) else { return false } + expected[entry.relativePath] = entry.sha256 + } guard let enumerator = fm.enumerator(atPath: dirPath) else { return false } while let file = enumerator.nextObject() as? String { diff --git a/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift b/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift index 828d12ca7..b9bc2d0b3 100644 --- a/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift +++ b/native-modules/react-native-bundle-crypto/ios/tests/BundleCryptoCoreTests.swift @@ -96,6 +96,37 @@ final class BundleCryptoCoreTests: XCTestCase { } } + func testDirectoryHashesRejectExpectedPathsOutsideBundle() throws { + try withTemporaryDirectory { root in + let bundle = root.appendingPathComponent("bundle", isDirectory: true) + let outside = root.appendingPathComponent("outside.txt") + try FileManager.default.createDirectory(at: bundle, withIntermediateDirectories: true) + try "outside".write(to: outside, atomically: true, encoding: .utf8) + let wrongHash = String(repeating: "0", count: 64) + + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes( + dirPath: bundle.path, + entries: [.init(relativePath: "../outside.txt", sha256: wrongHash)] + )) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes( + dirPath: bundle.path, + entries: [.init(relativePath: outside.path, sha256: wrongHash)] + )) + try FileManager.default.createSymbolicLink( + at: bundle.appendingPathComponent("link.txt"), + withDestinationURL: outside + ) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes( + dirPath: bundle.path, + entries: [.init(relativePath: "link.txt", sha256: wrongHash)] + )) + XCTAssertFalse(BundleCryptoCore.verifyDirAgainstHashes( + dirPath: bundle.path, + entries: [.init(relativePath: "", sha256: wrongHash)] + )) + } + } + private func withTemporaryDirectory(_ body: (URL) throws -> Void) throws { let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) diff --git a/native-views/react-native-text-input/android/build.gradle b/native-views/react-native-text-input/android/build.gradle index b103755dd..382fc4ff1 100644 --- a/native-views/react-native-text-input/android/build.gradle +++ b/native-views/react-native-text-input/android/build.gradle @@ -100,6 +100,10 @@ android { } } } + + testOptions { + unitTests.includeAndroidResources = true + } } repositories { @@ -115,6 +119,8 @@ dependencies { //noinspection GradleDynamicVersion implementation "com.facebook.react:react-android" implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + testImplementation "junit:junit:4.13.2" + testImplementation "org.robolectric:robolectric:4.17" } if (isNewArchitectureEnabled()) { diff --git a/native-views/react-native-text-input/android/src/test/java/com/textinput/TextInputViewPasteTest.kt b/native-views/react-native-text-input/android/src/test/java/com/textinput/TextInputViewPasteTest.kt new file mode 100644 index 000000000..24a00ef3d --- /dev/null +++ b/native-views/react-native-text-input/android/src/test/java/com/textinput/TextInputViewPasteTest.kt @@ -0,0 +1,116 @@ +package com.textinput + +import android.R +import android.content.ClipData +import android.content.ClipDescription +import android.content.ClipboardManager +import android.content.ContentProvider +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.net.Uri +import com.facebook.react.bridge.BridgeReactContext +import com.facebook.react.bridge.NativeModule +import com.facebook.react.uimanager.DisplayMetricsHolder +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowContentResolver +import so.onekey.app.wallet.pasteinput.PasteWatcher + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TextInputViewPasteTest { + @Test + fun textPasteReportsAndRunsNativePasteForBothMenuActions() { + for (action in listOf(R.id.paste, R.id.pasteAsPlainText)) { + val context = context() + clipboard(context).setPrimaryClip(ClipData.newPlainText("text", "copied")) + val events = mutableListOf>() + val view = view(context, events) + + assertTrue(view.onTextContextMenuItem(action)) + assertEquals(listOf(ClipDescription.MIMETYPE_TEXT_PLAIN to "copied"), events) + assertEquals("copied", view.text.toString()) + } + } + + @Test + fun uriPasteReportsResolvedMime() { + val context = context() + val uri = Uri.parse("content://text-input-image/item") + ShadowContentResolver.registerProviderInternal("text-input-image", MimeProvider("image/png")) + clipboard(context).setPrimaryClip(uriClip(uri)) + val events = mutableListOf>() + val view = view(context, events) + + assertTrue(view.onTextContextMenuItem(R.id.paste)) + assertEquals(listOf("image/png" to uri.toString()), events) + } + + @Test + fun uriWithoutResolvedMimeDoesNotReport() { + val context = context() + val uri = Uri.parse("content://text-input-no-mime/item") + ShadowContentResolver.registerProviderInternal("text-input-no-mime", MimeProvider(null)) + clipboard(context).setPrimaryClip(uriClip(uri)) + val events = mutableListOf>() + val view = view(context, events) + + assertTrue(view.onTextContextMenuItem(R.id.paste)) + assertTrue(events.isEmpty()) + } + + @Test + fun removedWatcherDoesNotReportButStillRunsNativePaste() { + val context = context() + clipboard(context).setPrimaryClip(ClipData.newPlainText("text", "copied")) + val events = mutableListOf>() + val view = view(context, events) + view.setPasteWatcher(null) + + assertTrue(view.onTextContextMenuItem(R.id.paste)) + assertTrue(events.isEmpty()) + assertEquals("copied", view.text.toString()) + } + + private fun context(): TestReactContext { + val application = RuntimeEnvironment.getApplication() + DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(application) + return TestReactContext(application) + } + + private fun clipboard(context: Context) = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + private fun view(context: Context, events: MutableList>) = + TextInputView(context).apply { + setPasteWatcher(object : PasteWatcher { + override fun onPaste(type: String, data: String) { + events += type to data + } + }) + } + + private fun uriClip(uri: Uri) = + ClipData(ClipDescription("uri", arrayOf("text/uri-list")), ClipData.Item(uri)) + + private class TestReactContext(base: Context) : BridgeReactContext(base) { + override fun getNativeModule(nativeModuleInterface: Class): T? = null + } + + private class MimeProvider(private val mime: String?) : ContentProvider() { + override fun onCreate() = true + override fun getType(uri: Uri): String? = mime + override fun query(uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String?): Cursor? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, + selectionArgs: Array?): Int = 0 + } +} diff --git a/native-views/react-native-text-input/docs/SPEC.md b/native-views/react-native-text-input/docs/SPEC.md index 82bde5219..39abc5271 100644 --- a/native-views/react-native-text-input/docs/SPEC.md +++ b/native-views/react-native-text-input/docs/SPEC.md @@ -77,8 +77,10 @@ Paste appears and emits the image event in both cases. The Android implementation is in `android/src/main/java/com/textinput/` (`TextInputView.kt`, `TextInputViewManager.kt`, and -`TextInputPasteEvent.kt`). No focused Android paste test exists yet. Device -acceptance should check text and image-URI clipboard items with `onPaste` -enabled and disabled, missing URI/MIME fallback, and that the underlying -plain-text paste action still runs. Neither platform's interaction cases have -been runtime verified for this change. +`TextInputPasteEvent.kt`). Focused Android Robolectric tests exercise both +Paste menu actions for text, URI reporting with and without a resolved MIME +type, watcher removal, and the underlying plain-text paste action. Device +acceptance should still check text and image-URI clipboard items with `onPaste` +enabled and disabled, missing URI/MIME fallback, and the actual menu interaction. +Neither platform's device interaction cases have been runtime verified for this +change.