diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 227fe17c638..00000000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,82 +0,0 @@ -version: 2.1 -jobs: - - lint: - docker: - - image: cimg/node:16.20.2 - - working_directory: ~/repo - - steps: - - checkout - - - restore_cache: - key: node_modules-{{ checksum "package-lock.json" }} - - - run: test -d node_modules || npm i - - - save_cache: - key: node_modules-{{ checksum "package-lock.json" }} - paths: - - node_modules - - # run tests! - - run: - command: npm run tslint && npm run lint - - unit: - docker: - - image: cimg/node:16.20.2 - - working_directory: ~/repo - - steps: - - checkout - - - restore_cache: - key: node_modules-{{ checksum "package-lock.json" }} - - - run: test -d node_modules || npm i - - - save_cache: - key: node_modules-{{ checksum "package-lock.json" }} - paths: - - node_modules - - # run tests! - - run: - command: npm run unit - - - integration: - docker: - - image: cimg/node:16.20.2 - - working_directory: ~/repo - - resource_class: large - - steps: - - checkout - - - restore_cache: - key: node_modules-{{ checksum "package-lock.json" }} - - - run: test -d node_modules || npm i - - - save_cache: - key: node_modules-{{ checksum "package-lock.json" }} - paths: - - node_modules - - # run tests! - - run: - command: npm run jest || npm run jest || npm run jest - -# Orchestrate our job run sequence -workflows: - build_and_test: - jobs: - - lint - - unit - - integration diff --git a/.detoxrc.json b/.detoxrc.json index 5adda885c91..bcb9033d8f7 100644 --- a/.detoxrc.json +++ b/.detoxrc.json @@ -7,10 +7,15 @@ } }, "apps": { - "ios": { + "ios.debug": { "type": "ios.app", - "binaryPath": "SPECIFY_PATH_TO_YOUR_APP_BINARY", - "build": "xcodebuild clean build -workspace ios/BlueWallet.xcworkspace -scheme BlueWallet -configuration Release -derivedDataPath ios/build -sdk iphonesimulator13.2" + "binaryPath": "ios/build/Build/Products/Debug-iphonesimulator/BlueWallet.app", + "build": "xcodebuild -workspace ios/BlueWallet.xcworkspace -scheme BlueWallet -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build" + }, + "ios.release": { + "type": "ios.app", + "binaryPath": "ios/build/Build/Products/Release-iphonesimulator/BlueWallet.app", + "build": "npx react-native codegen && xcodebuild -workspace ios/BlueWallet.xcworkspace -scheme BlueWallet -configuration Release -sdk iphonesimulator -derivedDataPath ios/build" }, "android.debug": { "type": "android.apk", @@ -21,14 +26,14 @@ "type": "android.apk", "testBinaryPath": "android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk", "binaryPath": "android/app/build/outputs/apk/release/app-release.apk", - "build": "find android | grep '\\.apk' --color=never | xargs -l rm\n\n# creating fresh keystore\nrm detox.keystore\nkeytool -genkeypair -v -keystore detox.keystore -alias detox -keyalg RSA -keysize 2048 -validity 10000 -storepass 123456 -keypass 123456 -dname 'cn=Unknown, ou=Unknown, o=Unknown, c=Unknown'\n\n# building release APK\ncd android && ./gradlew assembleRelease assembleAndroidTest -DtestBuildType=release && cd ..\n\n# wip\nfind $ANDROID_HOME | grep apksigner\n\n# signing\nmv ./android/app/build/outputs/apk/release/app-release-unsigned.apk ./android/app/build/outputs/apk/release/app-release.apk\n$ANDROID_HOME/build-tools/30.0.2/apksigner sign --ks detox.keystore --ks-pass=pass:123456 ./android/app/build/outputs/apk/release/app-release.apk\n$ANDROID_HOME/build-tools/30.0.2/apksigner sign --ks detox.keystore --ks-pass=pass:123456 ./android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk" + "build": "./tests/e2e/detox-build-release-apk.sh" } }, "devices": { "simulator": { "type": "ios.simulator", "device": { - "type": "iPhone 11" + "type": "iPhone 17" } }, "emulator": { @@ -39,14 +44,36 @@ } }, "configurations": { - "ios": { + "ios.debug": { "device": "simulator", - "app": "ios" + "app": "ios.debug" + }, + "ios.release": { + "device": "simulator", + "app": "ios.release" }, "android.debug": { "device": "emulator", "app": "android.debug" }, + "android.debug.device": { + "device": { + "device": { + "adbName": ".*" + }, + "type": "android.attached" + }, + "app": "android.debug" + }, + "android.release.device": { + "device": { + "device": { + "adbName": ".*" + }, + "type": "android.attached" + }, + "app": "android.release" + }, "android.release": { "device": "emulator", "app": "android.release" diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000000..b7b02be539d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,57 @@ +# Node modules +node_modules/ +npm-debug.log +yarn-error.log + +# Android build artifacts +android/.gradle/ +android/build/ +android/app/build/ +android/.kotlin/ +android/app/.cxx/ +reproducible-builds/build + +# Git +.git/ + +# IDE files +.idea/ +*.iml +*.classpath +*.project +android/.settings/ +android/app/.settings/ +ios/BlueWallet.xcodeproj/xcuserdata/ +.vscode/ +.vs/ + +# Temporary / system files +.DS_Store +*.hprof +.metro-health-check* +artifacts/ + +# Fastlane outputs +fastlane/screenshots/ +fastlane/test_output/ +fastlane/report.xml +fastlane/Preview.html +fastlane/README.md + +# BlueWallet specific +release-notes.json +release-notes.txt +current-branch.json + +# iOS / Xcode +ios/build/ +build/ +DerivedData/ +*.xcuserstate +*.pbxuser +*.mode1v3 +*.mode2v3 +*.perspectivev3 +.cxx/ +*.ipa +*.hmap \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 7bc38aa980f..2f3f6abc4e6 100644 --- a/.eslintrc +++ b/.eslintrc @@ -2,7 +2,7 @@ "parser": "@typescript-eslint/parser", "plugins": [ "@typescript-eslint", - "react-native" // for no-inline-styles rule + "react-native", // for no-inline-styles rule ], "extends": [ "standard", @@ -11,7 +11,7 @@ "plugin:react-hooks/recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", - "@react-native-community", + "@react-native", "plugin:prettier/recommended" // removes all eslint rules that can mess up with prettier ], "rules": { @@ -19,7 +19,12 @@ "react/display-name": "off", "react-native/no-inline-styles": "error", "react-native/no-unused-styles": "error", + "react/no-is-mounted": "off", "react-native/no-single-element-style-arrays": "error", + "react-hooks/refs": "off", + "react-hooks/immutability": "off", + "react-hooks/purity": "off", + "react-hooks/set-state-in-effect": "off", "prettier/prettier": [ "warn", { @@ -39,7 +44,18 @@ "ts-check": false } ], - "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }], + // v8 recommended defaults caughtErrors to "all"; keep prior behavior + "@typescript-eslint/no-unused-vars": ["error", { "args": "none", "caughtErrors": "none" }], + // v8 recommended enables a stricter TS variant; match prior standard allowances + "no-unused-expressions": "off", + "@typescript-eslint/no-unused-expressions": [ + "error", + { + "allowShortCircuit": true, + "allowTernary": true, + "allowTaggedTemplates": true + } + ], // disable rules that are superseded by @typescript-eslint rules "no-unused-vars": "off", @@ -47,9 +63,12 @@ // disable rules that we want to enforce only for typescript files "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-this-alias": "off", - "@typescript-eslint/no-use-before-define": "off" + "@typescript-eslint/no-use-before-define": "off", + + // v8 recommended successors / additions — preserve prior lint allowlist + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-empty-object-type": "off", }, "overrides": [ { @@ -59,7 +78,6 @@ "@typescript-eslint/explicit-module-boundary-types": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-this-alias": "off", - "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-inferrable-types": "off", "@typescript-eslint/no-use-before-define": ["error", { "variables": false }] } diff --git a/.github/ISSUE_TEMPLATE/issue-template.md b/.github/ISSUE_TEMPLATE/issue-template.md index d89b00f945a..c531098b920 100644 --- a/.github/ISSUE_TEMPLATE/issue-template.md +++ b/.github/ISSUE_TEMPLATE/issue-template.md @@ -16,7 +16,7 @@ Please provide: * your phone model and OS version * BlueWallet app version (settings->about->scroll down) * self-test passes? Open settings->about->scroll down, tap "Run self-test" -* unique ID for our crash reporting service (settings->about->scroll down, tap "copy") +* unique ID for our crash reporting service (option 1: settings->about->scroll down, tap "copy")(option 2: open the settings app->apps->BlueWallet,double tap the unique id text field and select copy) ## Proposing a feature? diff --git a/.github/workflows/build-ios-release-pullrequest.yml b/.github/workflows/build-ios-release-pullrequest.yml new file mode 100644 index 00000000000..7d346bb4462 --- /dev/null +++ b/.github/workflows/build-ios-release-pullrequest.yml @@ -0,0 +1,548 @@ +name: Build Release and Upload to TestFlight (iOS) + +on: + push: + branches: + - master + pull_request: + types: [opened, reopened, synchronize, labeled] + branches: + - master + workflow_dispatch: + +concurrency: + group: build-ios-release-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-26 + timeout-minutes: 180 + outputs: + new_build_number: ${{ steps.generate_build_number.outputs.build_number }} + project_version: ${{ steps.determine_marketing_version.outputs.project_version }} + ipa_output_path: ${{ steps.build_app.outputs.ipa_output_path }} + latest_commit_message: ${{ steps.get_latest_commit_details.outputs.commit_message }} + branch_name: ${{ steps.get_latest_commit_details.outputs.branch_name }} + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + MATCH_READONLY: "true" + + steps: + - name: Checkout Project + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 # Ensures the full Git history is + + - name: Ensure Correct Branch + if: github.ref != 'refs/heads/master' + run: | + if [ -n "${GITHUB_HEAD_REF}" ]; then + git fetch origin ${GITHUB_HEAD_REF}:${GITHUB_HEAD_REF} + git checkout ${GITHUB_HEAD_REF} + else + git fetch origin ${GITHUB_REF##*/}:${GITHUB_REF##*/} + git checkout ${GITHUB_REF##*/} + fi + echo "Checked out branch: $(git rev-parse --abbrev-ref HEAD)" + + - name: Get Latest Commit Details + id: get_latest_commit_details + run: | + # Check if we are in a detached HEAD state + if [ "$(git rev-parse --abbrev-ref HEAD)" == "HEAD" ]; then + CURRENT_BRANCH=$(git show-ref --head -s HEAD | xargs -I {} git branch --contains {} | grep -v "detached" | head -n 1 | sed 's/^[* ]*//') + else + CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) + fi + + LATEST_COMMIT_MESSAGE=$(git log -1 --pretty=format:"%s") + + echo "CURRENT_BRANCH=${CURRENT_BRANCH}" >> $GITHUB_ENV + echo "LATEST_COMMIT_MESSAGE=${LATEST_COMMIT_MESSAGE}" >> $GITHUB_ENV + echo "branch_name=${CURRENT_BRANCH}" >> $GITHUB_OUTPUT + echo "commit_message=${LATEST_COMMIT_MESSAGE}" >> $GITHUB_OUTPUT + + - name: Print Commit Details + run: | + echo "Commit Message: ${{ env.LATEST_COMMIT_MESSAGE }}" + echo "Branch Name: ${{ env.CURRENT_BRANCH }}" + + - name: Specify Node.js Version + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest + + - name: Setup Xcode Path + run: | + echo -e "\033[1;34m==================== XCODE SETUP DEBUG ====================\033[0m" + echo -e "\033[1;36mSystem Information:\033[0m" + echo " macOS Version: $(sw_vers -productVersion)" + echo " macOS Build: $(sw_vers -buildVersion)" + echo " Architecture: $(uname -m)" + + echo -e "\033[1;36mAvailable Xcode Installations:\033[0m" + find /Applications -name "Xcode*.app" -type d 2>/dev/null | while read -r xcode_path; do + if [ -d "$xcode_path/Contents/Developer" ]; then + echo " Found: $xcode_path" + version=$("$xcode_path/Contents/Developer/usr/bin/xcodebuild" -version 2>/dev/null | head -1 || echo "Version unavailable") + echo " Version: $version" + fi + done + + echo -e "\033[1;36mCurrent Xcode Configuration:\033[0m" + echo " Before xcode-select: $(xcode-select -p 2>/dev/null || echo 'Not set')" + + # Ensure we're using the correct Xcode installation + sudo xcode-select -s /Applications/Xcode.app + + echo " After xcode-select: $(xcode-select -p)" + echo " Xcode Version: $(xcodebuild -version)" + echo " Xcode Build Version: $(xcodebuild -version | tail -1)" + + echo -e "\033[1;36mSDK Information:\033[0m" + echo " Available SDKs:" + xcodebuild -showsdks | grep -E "(iOS|macOS)" | head -10 + + echo -e "\033[1;34m========================================================\033[0m" + + - name: Install Simulator Runtime (iOS) + run: | + echo -e "\033[1;34m============ SIMULATOR RUNTIME SETUP DEBUG ============\033[0m" + echo -e "\033[1;33mNote: Installing runtimes for debugging purposes - release builds use physical device targets\033[0m" + echo -e "\033[1;36mInitial Runtime Analysis:\033[0m" + echo " All available runtimes:" + xcrun simctl list runtimes | cat -n + + echo -e "\033[1;36mRuntime Summary:\033[0m" + echo " iOS Runtimes:" + xcrun simctl list runtimes | grep "iOS" | while read -r line; do + echo " $line" + done + + echo -e "\033[1;33mChecking iOS Runtime Requirements (17+):\033[0m" + if ! xcrun simctl list runtimes | grep -Eq "iOS (1[7-9]|[2-9][0-9])"; then + echo -e "\033[1;31m No iOS 17+ runtime found - installing...\033[0m" + echo " Downloading iOS platform..." + xcodebuild -downloadPlatform iOS + echo -e "\033[1;32m iOS platform download completed\033[0m" + else + echo -e "\033[1;32m iOS 17+ runtime already present\033[0m" + xcrun simctl list runtimes | grep -E "iOS (1[7-9]|[2-9][0-9])" | while read -r line; do + echo " Found: $line" + done + fi + + echo -e "\033[1;36mFinal Runtime Analysis:\033[0m" + echo " All runtimes after installation:" + xcrun simctl list runtimes | cat -n + + echo -e "\033[1;36mDevice Analysis & Setup:\033[0m" + echo " Current iOS devices:" + xcrun simctl list devices iOS | cat -n + + echo -e "\033[1;33mChecking for available iPhone simulators:\033[0m" + IPHONE_COUNT=$(xcrun simctl list devices iOS | grep -c "iPhone" || echo "0") + echo " iPhone simulators found: $IPHONE_COUNT" + + if [ "$IPHONE_COUNT" -eq "0" ]; then + echo -e "\033[1;31m No iPhone simulators found - creating one...\033[0m" + + # Get the latest iOS runtime available + echo " Finding latest iOS runtime:" + LATEST_IOS=$(xcrun simctl list runtimes | grep "iOS" | tail -1 | sed 's/.*iOS \([0-9.]*\).*/\1/') + echo " Latest iOS version detected: $LATEST_IOS" + + if [ -n "$LATEST_IOS" ]; then + RUNTIME_ID="com.apple.CoreSimulator.SimRuntime.iOS-${LATEST_IOS//./-}" + DEVICE_TYPE="com.apple.CoreSimulator.SimDeviceType.iPhone-15" + + echo " Creating iPhone 15 simulator:" + echo " Device Type: $DEVICE_TYPE" + echo " Runtime ID: $RUNTIME_ID" + + if xcrun simctl create "iPhone 15" "$DEVICE_TYPE" "$RUNTIME_ID"; then + echo -e "\033[1;32m Successfully created iPhone 15 with iOS $LATEST_IOS\033[0m" + else + echo -e "\033[1;33m Failed to create iPhone 15, trying iPhone 14...\033[0m" + xcrun simctl create "iPhone 14" "com.apple.CoreSimulator.SimDeviceType.iPhone-14" "$RUNTIME_ID" || echo -e "\033[1;31m Failed to create any iPhone simulator\033[0m" + fi + else + echo -e "\033[1;31m No iOS runtime available for device creation\033[0m" + fi + else + echo -e "\033[1;32m iPhone simulators already available\033[0m" + fi + + echo -e "\033[1;36mFinal Device Status:\033[0m" + echo " All iOS devices:" + xcrun simctl list devices iOS | cat -n + + echo -e "\033[1;36mDevice Type Analysis:\033[0m" + echo " Available device types:" + xcrun simctl list devicetypes | grep -i iphone | head -5 + + echo -e "\033[1;34m======================================================\033[0m" + + - name: Set Up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 3.4.10 + + - name: System Debug Information + run: | + echo -e "\033[1;34m================ SYSTEM DEBUG INFORMATION ================\033[0m" + echo -e "\033[1;36mSystem Overview:\033[0m" + echo " Hostname: $(hostname)" + echo " User: $(whoami)" + echo " Home: $HOME" + echo " Shell: $SHELL" + echo " PATH (first 5 entries):" + echo "$PATH" | tr ':' '\n' | head -5 | sed 's/^/ /' + + echo -e "\033[1;36mDisk Space:\033[0m" + df -h / | tail -1 | awk '{print " Available: " $4 " (" $5 " used)"}' + + echo -e "\033[1;36mMemory:\033[0m" + vm_stat | grep "Pages free" | awk '{print " Free Pages: " $3}' + + echo -e "\033[1;36mPackage Managers:\033[0m" + echo " Bundle version: $(bundle --version 2>/dev/null || echo 'Not available')" + echo " NPM version: $(npm --version 2>/dev/null || echo 'Not available')" + echo " CocoaPods version: $(pod --version 2>/dev/null || echo 'Not available')" + + echo -e "\033[1;36mEnvironment Variables (Build-related):\033[0m" + env | grep -E "(GITHUB_|CI|RUNNER_|PROJECT_|NEW_BUILD)" | sort | head -10 + + echo -e "\033[1;34m========================================================\033[0m" + + - name: Install Dependencies with Bundler + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --quiet + + - name: Install Node Modules + run: npm ci --omit=dev --yes + + - name: Cache CocoaPods + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + ios/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-ios-release-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-ios-release- + + - name: Install CocoaPods Dependencies + env: + RCT_USE_RN_DEP: "1" + RCT_USE_PREBUILT_RNCORE: "1" + run: | + bundle exec fastlane ios install_pods + echo "CocoaPods dependencies installed successfully" + + - name: Generate Build Number Based on Timestamp + id: generate_build_number + run: | + NEW_BUILD_NUMBER=$(date +%s) + echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV + echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT + + - name: Set Build Number + run: bundle exec fastlane ios increment_build_number_lane + + - name: Determine Marketing Version + id: determine_marketing_version + run: | + MARKETING_VERSION=$(grep MARKETING_VERSION BlueWallet.xcodeproj/project.pbxproj | awk -F '= ' '{print $2}' | tr -d ' ;' | head -1) + echo "PROJECT_VERSION=$MARKETING_VERSION" >> $GITHUB_ENV + echo "project_version=$MARKETING_VERSION" >> $GITHUB_OUTPUT + working-directory: ios + + - name: Set Up Git Authentication + env: + ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + run: | + git config --global credential.helper 'cache --timeout=3600' + git config --global http.https://github.com/.extraheader "AUTHORIZATION: basic $(echo -n x-access-token:${ACCESS_TOKEN} | base64)" + + - name: Create Temporary Keychain + run: bundle exec fastlane ios create_temp_keychain + env: + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: Setup Provisioning Profiles + env: + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + GIT_URL: ${{ secrets.GIT_URL }} + ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }} + ITC_TEAM_NAME: ${{ secrets.ITC_TEAM_NAME }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + run: | + bundle exec fastlane ios setup_provisioning_profiles + + - name: Build App + id: build_app + run: | + echo -e "\033[1;34m==================== BUILD APP DEBUG ====================\033[0m" + echo -e "\033[1;36mPre-Build Environment Check:\033[0m" + echo " Working Directory: $(pwd)" + echo " iOS Directory Contents:" + ls -la ios/ || echo -e "\033[1;31m iOS directory not found\033[0m" + + echo -e "\033[1;36mBuild Configuration:\033[0m" + echo " PROJECT_VERSION: ${PROJECT_VERSION:-'Not set'}" + echo " NEW_BUILD_NUMBER: ${NEW_BUILD_NUMBER:-'Not set'}" + echo " Build Type: Release (App Store)" + + echo -e "\033[1;33mXcode Project Analysis:\033[0m" + if [ -f "ios/BlueWallet.xcworkspace" ]; then + echo -e "\033[1;32m Workspace found: ios/BlueWallet.xcworkspace\033[0m" + else + echo -e "\033[1;31m Workspace missing: ios/BlueWallet.xcworkspace\033[0m" + fi + + if [ -f "ios/export_options.plist" ]; then + echo -e "\033[1;32m Export options found: ios/export_options.plist\033[0m" + echo " Export options content:" + cat ios/export_options.plist | head -20 + else + echo -e "\033[1;31m Export options missing: ios/export_options.plist\033[0m" + fi + + echo -e "\033[1;33mAvailable Build Destinations:\033[0m" + if [ -f "ios/BlueWallet.xcworkspace" ]; then + xcodebuild -workspace ios/BlueWallet.xcworkspace -scheme BlueWallet -showdestinations 2>/dev/null | head -20 || echo -e "\033[1;31m Failed to get destinations\033[0m" + fi + + echo -e "\033[1;35mStarting Fastlane Build (Release Mode):\033[0m" + bundle exec fastlane ios build_app_lane + + echo -e "\033[1;36mPost-Build IPA Analysis:\033[0m" + echo " Searching for IPA files..." + find ./ios -name "*.ipa" -type f 2>/dev/null | while read -r ipa; do + echo " Found IPA: $ipa" + echo " Size: $(ls -lh "$ipa" | awk '{print $5}')" + echo " Modified: $(ls -l "$ipa" | awk '{print $6, $7, $8}')" + done + + # Ensure IPA path is set for subsequent steps + if [ -f "./ios/build/ipa_path.txt" ]; then + IPA_PATH=$(cat ./ios/build/ipa_path.txt) + echo -e "\033[1;32m IPA path from file: $IPA_PATH\033[0m" + echo "IPA_OUTPUT_PATH=$IPA_PATH" >> $GITHUB_ENV + echo "ipa_output_path=$IPA_PATH" >> $GITHUB_OUTPUT + else + echo -e "\033[1;33m ipa_path.txt not found, searching manually...\033[0m" + IPA_PATH=$(find ./ios -name "*.ipa" | head -n 1) + if [ -n "$IPA_PATH" ]; then + echo -e "\033[1;32m IPA found manually: $IPA_PATH\033[0m" + echo "IPA_OUTPUT_PATH=$IPA_PATH" >> $GITHUB_ENV + echo "ipa_output_path=$IPA_PATH" >> $GITHUB_OUTPUT + else + echo -e "\033[1;31m No IPA file found anywhere\033[0m" + echo -e "\033[1;33m Directory structure for debugging:\033[0m" + find ./ios -type f -name "*.xcarchive" -o -name "*.ipa" -o -name "build_logs" 2>/dev/null || echo " No build artifacts found" + exit 1 + fi + fi + + echo -e "\033[1;32mBuild Summary:\033[0m" + echo " Final IPA Path: ${IPA_OUTPUT_PATH:-'Not set'}" + if [ -n "${IPA_OUTPUT_PATH}" ] && [ -f "${IPA_OUTPUT_PATH}" ]; then + echo -e "\033[1;32m IPA file exists and is ready for release\033[0m" + echo " File info: $(ls -lh "${IPA_OUTPUT_PATH}")" + fi + echo -e "\033[1;34m========================================================\033[0m" + + - name: Debug Build Failure + if: failure() + run: | + echo -e "\033[1;31m================== BUILD FAILURE DEBUG ==================\033[0m" + echo -e "\033[1;31mBuild failure analysis initiated...\033[0m" + + echo -e "\033[1;36mProject Structure Analysis:\033[0m" + echo " iOS directory contents:" + ls -la ios/ 2>/dev/null || echo -e "\033[1;31m iOS directory not accessible\033[0m" + + echo " Build directory contents:" + if [ -d "ios/build" ]; then + find ios/build -type f -name "*.log" -o -name "*.ipa" -o -name "*.xcarchive" | head -10 + else + echo -e "\033[1;31m ios/build directory does not exist\033[0m" + fi + + echo -e "\033[1;36mBuild Logs Analysis:\033[0m" + if [ -d "ios/build_logs" ]; then + echo " Build logs directory contents:" + ls -la ios/build_logs/ | head -10 + + echo " Recent log files (last 50 lines each):" + find ios/build_logs -name "*.log" -type f | head -3 | while read -r logfile; do + echo " === $logfile ===" + tail -50 "$logfile" 2>/dev/null | head -20 + echo " === End of $logfile ===" + done + else + echo -e "\033[1;31m No build logs directory found\033[0m" + fi + + echo -e "\033[1;36mXcode Build System Analysis:\033[0m" + echo " Recent archives:" + find ~/Library/Developer/Xcode/Archives -name "*.xcarchive" -type d 2>/dev/null | tail -3 || echo " No archives found" + + echo " Derived data contents:" + find ~/Library/Developer/Xcode/DerivedData -maxdepth 2 -name "*BlueWallet*" 2>/dev/null | head -5 || echo " No derived data found" + + echo -e "\033[1;36mFinal Simulator State:\033[0m" + echo " Available runtimes:" + xcrun simctl list runtimes | grep -E "(iOS)" | tail -5 + + echo " Available devices:" + xcrun simctl list devices iOS | head -10 + + echo -e "\033[1;36mSystem State:\033[0m" + echo " Disk space:" + df -h / | tail -1 + + echo " Memory usage:" + vm_stat | grep -E "(Pages free|Pages active)" | head -2 + + echo -e "\033[1;31m========================================================\033[0m" + + - name: Upload Bugsnag Sourcemaps + if: success() + run: bundle exec fastlane ios upload_bugsnag_sourcemaps + env: + BUGSNAG_API_KEY: ${{ secrets.BUGSNAG_API_KEY }} + BUGSNAG_RELEASE_STAGE: production + PROJECT_VERSION: ${{ env.PROJECT_VERSION }} + NEW_BUILD_NUMBER: ${{ env.NEW_BUILD_NUMBER }} + + - name: Upload Build Logs + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build_logs + path: ./ios/build_logs/ + retention-days: 7 + + - name: Verify IPA File Before Upload + run: | + echo "Checking IPA file at: $IPA_OUTPUT_PATH" + if [ -f "$IPA_OUTPUT_PATH" ]; then + echo "✅ IPA file exists" + ls -la "$IPA_OUTPUT_PATH" + else + echo "❌ IPA file not found at: $IPA_OUTPUT_PATH" + echo "Current directory contents:" + find ./ios -name "*.ipa" + exit 1 + fi + + - name: Upload IPA as Artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BlueWallet_IPA + path: ${{ env.IPA_OUTPUT_PATH }} + retention-days: 7 + + - name: Delete Temporary Keychain + if: always() + run: bundle exec fastlane ios delete_temp_keychain + + testflight-upload: + needs: build + runs-on: macos-26 + if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'testflight') + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + NEW_BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }} + PROJECT_VERSION: ${{ needs.build.outputs.project_version }} + LATEST_COMMIT_MESSAGE: ${{ needs.build.outputs.latest_commit_message }} + BRANCH_NAME: ${{ needs.build.outputs.branch_name }} + steps: + - name: Checkout Project + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Set Up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 3.4.10 + + - name: Install Dependencies with Bundler + run: | + bundle config path vendor/bundle + bundle install --jobs 4 --retry 3 --quiet + + - name: Download IPA from Artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: BlueWallet_IPA + path: ./ + + - name: Create App Store Connect API Key JSON + run: echo '${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}' > ./appstore_api_key.json + + - name: Set IPA Path Environment Variable + run: echo "IPA_OUTPUT_PATH=$(pwd)/BlueWallet_${{ needs.build.outputs.project_version }}_${{ needs.build.outputs.new_build_number }}.ipa" >> $GITHUB_ENV + + - name: Verify IPA Path Before Upload + run: | + if [ ! -f "$IPA_OUTPUT_PATH" ]; then + echo "❌ IPA file not found at path: $IPA_OUTPUT_PATH" + ls -la $(pwd) + exit 1 + else + echo "✅ Found IPA at: $IPA_OUTPUT_PATH" + fi + + - name: Print Environment Variables for Debugging + run: | + echo "LATEST_COMMIT_MESSAGE: $LATEST_COMMIT_MESSAGE" + echo "BRANCH_NAME: $BRANCH_NAME" + echo "PROJECT_VERSION: $PROJECT_VERSION" + echo "NEW_BUILD_NUMBER: $NEW_BUILD_NUMBER" + echo "IPA_OUTPUT_PATH: $IPA_OUTPUT_PATH" + + - name: Upload to TestFlight + run: bundle exec fastlane ios upload_to_testflight_lane + env: + APP_STORE_CONNECT_API_KEY_PATH: $(pwd)/appstore_api_key.p8 + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + GIT_ACCESS_TOKEN: ${{ secrets.GIT_ACCESS_TOKEN }} + GIT_URL: ${{ secrets.GIT_URL }} + ITC_TEAM_ID: ${{ secrets.ITC_TEAM_ID }} + ITC_TEAM_NAME: ${{ secrets.ITC_TEAM_NAME }} + APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY_ID }} + APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ISSUER_ID }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: Post PR Comment + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BUILD_NUMBER: ${{ needs.build.outputs.new_build_number }} + PROJECT_VERSION: ${{ needs.build.outputs.project_version }} + LATEST_COMMIT_MESSAGE: ${{ needs.build.outputs.latest_commit_message }} + with: + script: | + const buildNumber = process.env.BUILD_NUMBER; + const version = process.env.PROJECT_VERSION; + const message = `✅ Build ${version} (${buildNumber}) has been uploaded to TestFlight and will be available for testing soon.`; + const prNumber = context.payload.pull_request.number; + const repo = context.repo; + github.rest.issues.createComment({ + ...repo, + issue_number: prNumber, + body: message, + }); \ No newline at end of file diff --git a/.github/workflows/build-mac-catalyst.yml b/.github/workflows/build-mac-catalyst.yml new file mode 100644 index 00000000000..7292b330975 --- /dev/null +++ b/.github/workflows/build-mac-catalyst.yml @@ -0,0 +1,187 @@ +name: Build Mac Catalyst + +on: + workflow_dispatch: + pull_request: + branches: + - master + types: [labeled, synchronize] + +concurrency: + group: catalyst-build-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +jobs: + build: + if: > + github.event_name == 'workflow_dispatch' || + (github.event.action == 'labeled' && (github.event.label.name == 'mac-dmg' || github.event.label.name == 'testflight')) || + github.event.action == 'synchronize' + runs-on: macos-15 + timeout-minutes: 120 + + steps: + - name: Check PR labels + if: github.event_name == 'pull_request' + id: labels + env: + GH_TOKEN: ${{ github.token }} + run: | + LABELS=$(gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/labels" --jq '.[].name' | tr '\n' ',') + echo "all=${LABELS}" >> $GITHUB_OUTPUT + if [[ "$LABELS" == *"mac-dmg"* ]]; then + echo "has_mac_dmg=true" >> $GITHUB_OUTPUT + else + echo "has_mac_dmg=false" >> $GITHUB_OUTPUT + fi + if [[ "$LABELS" == *"testflight"* ]] && [[ "$LABELS" == *"mac-dmg"* ]]; then + echo "upload_testflight=true" >> $GITHUB_OUTPUT + else + echo "upload_testflight=false" >> $GITHUB_OUTPUT + fi + echo "Labels on PR: ${LABELS}" + + - name: Skip if mac-dmg label not present + if: github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg != 'true' + run: | + echo "mac-dmg label not found on PR — skipping build." + exit 0 + + - name: Checkout project + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + + - name: Setup Node.js + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - name: Setup Xcode + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1.7.0 + with: + xcode-version: latest + + - name: Set up Ruby + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: 3.4.10 + bundler-cache: true + + - name: Install Node modules + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + run: npm ci + + - name: Cache CocoaPods + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + ios/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-catalyst-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-catalyst- + + - name: Install CocoaPods dependencies + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + env: + SKIP_APP_STORE_CONNECT_AUTH: '1' + RCT_USE_RN_DEP: "1" + RCT_USE_PREBUILT_RNCORE: "1" + run: bundle exec fastlane ios install_pods + + - name: Create temporary keychain for signing + if: (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true') && steps.labels.outputs.upload_testflight == 'true' + run: | + security create-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain + security default-keychain -s build.keychain + security unlock-keychain -p "${{ secrets.KEYCHAIN_PASSWORD }}" build.keychain + security set-keychain-settings -t 3600 -u build.keychain + + - name: Build Mac Catalyst app with Fastlane + if: github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true' + id: build_catalyst + run: bundle exec fastlane ios build_catalyst_app_lane + env: + SKIP_APP_STORE_CONNECT_AUTH: '1' + SKIP_CLEAR_DERIVED_DATA: '1' + CATALYST_SIGNING_IDENTITY: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_SIGNING_IDENTITY || '' }} + CATALYST_TEAM_ID: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.CATALYST_TEAM_ID || '' }} + GIT_URL: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_URL || '' }} + GIT_ACCESS_TOKEN: ${{ steps.labels.outputs.upload_testflight == 'true' && secrets.GIT_ACCESS_TOKEN || '' }} + MATCH_READONLY: ${{ steps.labels.outputs.upload_testflight == 'true' && 'false' || 'true' }} + KEYCHAIN_NAME: ${{ steps.labels.outputs.upload_testflight == 'true' && 'build' || '' }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + + - name: Upload Mac Catalyst DMG + id: upload_dmg + if: success() && (github.event_name == 'workflow_dispatch' || steps.labels.outputs.has_mac_dmg == 'true') + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: BlueWallet-Mac-Catalyst + path: ${{ steps.build_catalyst.outputs.catalyst_dmg_path }} + if-no-files-found: warn + + - name: Create App Store Connect API Key JSON + if: success() && steps.labels.outputs.upload_testflight == 'true' + run: echo '${{ secrets.APPLE_API_KEY_CONTENT }}' > ./appstore_api_key.json + + - name: Upload to TestFlight + if: success() && steps.labels.outputs.upload_testflight == 'true' + run: bundle exec fastlane ios upload_catalyst_to_testflight + env: + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER_ID: ${{ secrets.APPLE_API_ISSUER_ID }} + CATALYST_TEAM_ID: ${{ secrets.CATALYST_TEAM_ID }} + TEAM_ID: ${{ secrets.TEAM_ID }} + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + LATEST_COMMIT_MESSAGE: ${{ github.event.pull_request.title || 'Manual build' }} + + - name: Cleanup App Store Connect API Key JSON + if: always() && steps.labels.outputs.upload_testflight == 'true' + run: rm -f ./appstore_api_key.json + + - name: Cleanup temporary keychain + if: always() && steps.labels.outputs.upload_testflight == 'true' + run: security delete-keychain build.keychain || true + + - name: Comment on PR with DMG link + if: success() && github.event_name == 'pull_request' && steps.labels.outputs.has_mac_dmg == 'true' + env: + GH_TOKEN: ${{ github.token }} + UPLOADED_TO_TF: ${{ steps.labels.outputs.upload_testflight }} + run: | + ARTIFACT_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts/${{ steps.upload_dmg.outputs.artifact-id }}" + COMMENT_TAG="" + + TF_LINE="" + if [[ "$UPLOADED_TO_TF" == "true" ]]; then + TF_LINE=$'\n\n**Also uploaded to TestFlight.** Check [App Store Connect](https://appstoreconnect.apple.com) for the build.' + fi + + COMMENT_FILE="$(mktemp)" + { + printf '%s\n' "${COMMENT_TAG}" + printf '### Mac Catalyst Build\n\n' + printf 'The Mac Catalyst DMG is ready for download:\n\n' + printf '[Download BlueWallet-Mac-Catalyst.dmg](%s)\n' "${ARTIFACT_URL}" + if [[ -n "$TF_LINE" ]]; then + printf '%s\n' "${TF_LINE}" + fi + printf 'Built from `%s`\n' "${{ github.sha }}" + } >"${COMMENT_FILE}" + + gh api "repos/${{ github.repository }}/issues/${{ github.event.pull_request.number }}/comments" \ + --paginate --jq '.[] | select(.body | contains("")) | .id' | \ + while read -r comment_id; do + gh api -X DELETE "repos/${{ github.repository }}/issues/comments/${comment_id}" || true + done + + gh pr comment "${{ github.event.pull_request.number }}" --body-file "${COMMENT_FILE}" diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml index e7526962929..9741c63c52a 100644 --- a/.github/workflows/build-release-apk.yml +++ b/.github/workflows/build-release-apk.yml @@ -1,47 +1,165 @@ name: BuildReleaseApk -on: [pull_request] + +on: + pull_request: + branches: + - master + types: [opened, synchronize, reopened, labeled, unlabeled] + push: + branches: + - master jobs: buildReleaseApk: - runs-on: macos-latest + runs-on: ubuntu-24.04 steps: - name: Checkout project - uses: actions/checkout@v3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: fetch-depth: "0" + - name: Free disk space (Android build) + shell: bash + run: | + df -h + sudo rm -rf /usr/share/dotnet || true + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/share/boost || true + sudo rm -rf /usr/local/lib/android/sdk/ndk || true + docker system prune -af || true + sudo rm -rf /usr/local/lib/android/sdk/system-images || true + sudo rm -rf /usr/local/lib/android/sdk/emulator || true + rm -rf ~/.gradle/caches/modules-2/files-2.1 || true + rm -rf ~/.gradle/caches/build-cache || true + rm -rf ~/.npm/_cacache ~/.cache || true + sudo rm -rf /home/runner/work/_temp || true + df -h + - name: Specify node version - uses: actions/setup-node@v2-beta + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 16 + node-version: 24 + cache: 'npm' - - name: Use npm caches - uses: actions/cache@v2 + - name: Use specific Java version for sdkmanager to work + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: - path: ~/.npm - key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} + distribution: 'temurin' + java-version: '17' + + - name: Use gradle caches + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }} restore-keys: | - ${{ runner.os }}-npm- + ${{ runner.os }}-gradle- - - name: Use specific Java version for sdkmanager to work - uses: actions/setup-java@v3 + - name: Set up Android SDK + uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1 + + - name: Install Android SDK components + run: | + yes | sdkmanager --licenses + sdkmanager "platforms;android-36" "platform-tools" "build-tools;36.0.0" "ndk;28.2.13676358" + + - name: Install Node Modules + run: npm ci --omit=dev --yes + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - distribution: 'temurin' - java-version: '11' - cache: 'gradle' + ruby-version: 3.4.10 + bundler-cache: true - - name: Install node_modules - run: npm install --production + - name: Generate Build Number based on timestamp + id: build_number + run: | + NEW_BUILD_NUMBER="$(date +%s)" + echo "NEW_BUILD_NUMBER=$NEW_BUILD_NUMBER" >> $GITHUB_ENV + echo "build_number=$NEW_BUILD_NUMBER" >> $GITHUB_OUTPUT - - name: Build + - name: Build and sign APK + id: build_and_sign_apk + run: bundle exec fastlane android build_release_apk env: + BUILD_NUMBER: ${{ steps.build_number.outputs.build_number }} KEYSTORE_FILE_HEX: ${{ secrets.KEYSTORE_FILE_HEX }} KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} - run: ./scripts/build-release-apk.sh - - uses: actions/upload-artifact@v2 - if: success() + - name: Upload build logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: android-build-logs + path: | + fastlane/logs/**/*.log + android/**/*.log + android/**/build/**/*.log + android/**/outputs/logs/**/*.log + android/**/reports/**/*.log + if-no-files-found: warn + + - name: Determine APK Filename and Path + id: determine_apk_path + run: | + BUILD_NUMBER=${{ steps.build_number.outputs.build_number }} + VERSION_NAME=$(grep versionName android/app/build.gradle | awk '{print $2}' | tr -d '"') + BRANCH_NAME=${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}} + BRANCH_NAME=$(echo "$BRANCH_NAME" | sed 's/[^a-zA-Z0-9_-]/_/g') + + if [ -n "$BRANCH_NAME" ] && [ "$BRANCH_NAME" != "master" ]; then + EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}-${BRANCH_NAME}.apk" + else + EXPECTED_FILENAME="BlueWallet-${VERSION_NAME}-${BUILD_NUMBER}.apk" + fi + + APK_PATH="android/app/build/outputs/apk/release/${EXPECTED_FILENAME}" + echo "EXPECTED_FILENAME=${EXPECTED_FILENAME}" >> $GITHUB_ENV + echo "APK_PATH=${APK_PATH}" >> $GITHUB_ENV + + - name: Upload APK as artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: signed-apk + path: ${{ env.APK_PATH }} + if-no-files-found: error + + browserstack: + runs-on: macos-26 + needs: buildReleaseApk + if: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'browserstack') }} + + steps: + - name: Checkout code + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Set up Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 with: - name: apk - path: ./android/app/build/outputs/apk/release/app-release.apk + ruby-version: 3.4.10 + bundler-cache: true + + - name: Install dependencies with Bundler + run: bundle install --jobs 4 --retry 3 + + - name: Download APK artifact + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: signed-apk + + - name: Set APK Path + run: | + APK_PATH=$(find ${{ github.workspace }} -name '*.apk') + echo "APK_PATH=$APK_PATH" >> $GITHUB_ENV + + - name: Upload APK to BrowserStack and Post PR Comment + env: + BROWSERSTACK_USERNAME: ${{ secrets.BROWSERSTACK_USERNAME }} + BROWSERSTACK_ACCESS_KEY: ${{ secrets.BROWSERSTACK_ACCESS_KEY }} + GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bundle exec fastlane upload_to_browserstack_and_comment diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ecd1930413..d58d005aae6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,45 +3,57 @@ name: Tests # https://dev.to/edvinasbartkus/running-react-native-detox-tests-for-ios-and-android-on-github-actions-2ekn # https://medium.com/@reime005/the-best-ci-cd-for-react-native-with-e2e-support-4860b4aaab29 +env: + HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} + HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} + on: [pull_request] jobs: - test: - runs-on: macos-latest + lint: + runs-on: ubuntu-latest steps: - name: Checkout project - uses: actions/checkout@v3 + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 - name: Specify node version - uses: actions/setup-node@v2-beta + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - node-version: 16 + node-version: 24 + cache: 'npm' + + - name: Install node_modules + run: npm ci || npm ci - - name: Use npm caches - uses: actions/cache@v2 + - name: Run tests + run: npm run lint + + unit: + runs-on: ubuntu-latest + steps: + - name: Checkout project + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - path: ~/.npm - key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-npm- + fetch-depth: 0 - - name: Use node_modules caches - id: cache-nm - uses: actions/cache@v2 + - name: Specify node version + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - path: node_modules - key: ${{ runner.os }}-nm-${{ hashFiles('package-lock.json') }} + node-version: 24 + cache: 'npm' - name: Install node_modules - if: steps.cache-nm.outputs.cache-hit != 'true' - run: npm install + run: npm ci || npm ci - name: Run tests - run: npm test || npm test || npm test + run: npm run unit env: BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}} HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }} + HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }} HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }} HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }} @@ -49,60 +61,34 @@ jobs: MNEMONICS_COBO: ${{ secrets.MNEMONICS_COBO }} MNEMONICS_COLDCARD: ${{ secrets.MNEMONICS_COLDCARD }} - e2e: - runs-on: macos-latest + integration: + runs-on: ubuntu-latest steps: - - name: checkout - uses: actions/checkout@v3 - - - name: Specify node version - uses: actions/setup-node@v2-beta - with: - node-version: 16 - - - name: Use gradle caches - uses: actions/cache@v2 + - name: Checkout project + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 with: - path: ~/.gradle/caches - key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} - restore-keys: | - ${{ runner.os }}-gradle- + fetch-depth: 0 - - name: Use npm caches - uses: actions/cache@v2 + - name: Specify node version + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 with: - path: ~/.npm - key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-npm- + node-version: 24 + cache: 'npm' - name: Install node_modules - run: npm install + run: npm ci || npm ci - - name: Use specific Java version for sdkmanager to work - uses: actions/setup-java@v2 - with: - distribution: 'temurin' - java-version: '11' - - - name: Build - run: npm run e2e:release-build - - - name: run tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 31 - avd-name: Pixel_API_29_AOSP - emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047 - arch: x86_64 - script: npm run e2e:release-test || npm run e2e:release-test || npm run e2e:release-test || npm run e2e:release-test + - name: Run tests + run: npm run integration || npm run integration || npm run integration || npm run integration env: - TRAVIS: 1 + BIP47_HD_MNEMONIC: ${{ secrets.BIP47_HD_MNEMONIC}} HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} + HD_MNEMONIC_BIP49: ${{ secrets.HD_MNEMONIC_BIP49 }} + HD_MNEMONIC_OLD: ${{ secrets.HD_MNEMONIC_OLD }} + HD_MNEMONIC_BIP49_MANY_TX: ${{ secrets.HD_MNEMONIC_BIP49_MANY_TX }} HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} - - - uses: actions/upload-artifact@v2 - if: failure() - with: - name: e2e-test-videos - path: ./artifacts/ + HD_MNEMONIC_BREAD: ${{ secrets.HD_MNEMONIC_BREAD }} + FAULTY_ZPUB: ${{ secrets.FAULTY_ZPUB }} + MNEMONICS_COBO: ${{ secrets.MNEMONICS_COBO }} + MNEMONICS_COLDCARD: ${{ secrets.MNEMONICS_COLDCARD }} + RETRY: 1 diff --git a/.github/workflows/e2e-android.yml b/.github/workflows/e2e-android.yml new file mode 100644 index 00000000000..5d0d57d0bf1 --- /dev/null +++ b/.github/workflows/e2e-android.yml @@ -0,0 +1,152 @@ +name: Tests e2e Android + +on: [pull_request] + +permissions: + contents: read + +concurrency: + group: e2e-android-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Free disk space (Ubuntu) + run: | + echo "Disk before cleanup:" && df -h + sudo rm -rf /usr/share/dotnet /opt/ghc + sudo apt-get clean + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/share/boost || true + sudo rm -rf /usr/local/lib/android/sdk/ndk || true + sudo docker system prune -af || true + sudo rm -rf /usr/local/lib/android/sdk/system-images || true + sudo rm -rf /usr/local/lib/android/sdk/emulator || true + rm -rf ~/.gradle/caches/modules-2/files-2.1 || true + rm -rf ~/.gradle/caches/build-cache || true + rm -rf ~/.npm/_cacache ~/.cache || true + sudo rm -rf /home/runner/work/_temp || true + echo "Disk after cleanup:" && df -h + + - name: Specify node version + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - name: Use gradle caches + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('android/**/*.gradle', 'android/**/*.properties') }} + restore-keys: | + ${{ runner.os }}-gradle- + + - name: Install node_modules + run: npm ci || npm ci + + - name: Use specific Java version for sdkmanager to work + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'temurin' + java-version: '17' + + - name: Build + run: npm run e2e:release-build || npm run e2e:release-build + + - name: Package APKs + run: | + tar -czf bluewallet-android-apks.tar.gz \ + android/app/build/outputs/apk/release/app-release.apk \ + android/app/build/outputs/apk/androidTest/release/app-release-androidTest.apk + + - name: Upload APKs + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bluewallet-android-apks + path: bluewallet-android-apks.tar.gz + retention-days: 3 + compression-level: 0 + if-no-files-found: error + + test: + runs-on: ubuntu-24.04 + needs: build + env: + HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} + HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Free disk space (Ubuntu) + run: | + echo "Disk before cleanup:" && df -h + sudo rm -rf /usr/share/dotnet /opt/ghc + sudo apt-get clean + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/share/boost || true + sudo rm -rf /usr/local/lib/android/sdk/ndk || true + sudo docker system prune -af || true + rm -rf ~/.npm/_cacache ~/.cache || true + sudo rm -rf /home/runner/work/_temp || true + echo "Disk after cleanup:" && df -h + + - name: Ensure artifacts directory + run: mkdir -p ${{ github.workspace }}/artifacts + + - name: Specify node version + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - name: Install node_modules + run: npm ci || npm ci + + - name: Use specific Java version for sdkmanager to work + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + distribution: 'temurin' + java-version: '17' + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Download APKs + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bluewallet-android-apks + + - name: Restore APKs + run: tar -xzf bluewallet-android-apks.tar.gz + + - name: Run tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + api-level: 36 + profile: pixel + avd-name: Pixel_API_29_AOSP + force-avd-creation: true + enable-hw-keyboard: true + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none -camera-front none -partition-size 2047 + arch: x86_64 + script: npm run e2e:release-test -- --record-videos failing --record-logs failing --take-screenshots failing --headless --retries 4 --reuse --artifacts-location ${{ github.workspace }}/artifacts + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() + with: + name: e2e-android-videos + path: ${{ github.workspace }}/artifacts diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml new file mode 100644 index 00000000000..0351bca4e42 --- /dev/null +++ b/.github/workflows/e2e-ios.yml @@ -0,0 +1,233 @@ +name: Tests e2e iOS + +on: [pull_request] + +permissions: + contents: read + +concurrency: + group: e2e-ios-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: macos-26 + env: + BUILD_CONFIGURATION: Release + CCACHE_MAXSIZE: "2G" + CCACHE_DIR: /Users/runner/.ccache + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - name: Setup Ruby + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0 + with: + ruby-version: "3.4.10" + bundler-cache: true + + - name: Install Node dependencies + run: npm ci || npm ci + + - name: Cache CocoaPods + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: | + ios/Pods + ~/Library/Caches/CocoaPods + ~/.cocoapods/repos + key: ${{ runner.os }}-pods-prebuilt-${{ hashFiles('ios/Podfile.lock') }} + restore-keys: | + ${{ runner.os }}-pods-prebuilt- + + - name: Install ccache + run: brew install ccache + + - name: Cache ccache + uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0 + with: + path: ~/.ccache + key: ${{ runner.os }}-ccache-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-ccache- + + - name: Delete extension targets + run: | + bundle exec ruby <<'RUBY' + require 'xcodeproj' + + project_path = 'ios/BlueWallet.xcodeproj' + project = Xcodeproj::Project.open(project_path) + + target_names = %w[WidgetsExtension Stickers] + embed_phase_names = ['Embed Watch Content', 'Embed Foundation Extensions'] + removed_any = false + + target_names.each do |target_name| + target = project.targets.find { |t| t.name == target_name } + next unless target + + puts "Removing target #{target_name}" + target.dependencies.each(&:remove_from_project) + target.build_phases.each(&:remove_from_project) + project.targets.delete(target) + removed_any = true + end + + main_target = project.targets.find { |t| t.name == 'BlueWallet' } + if main_target + main_target.build_phases.select { |phase| embed_phase_names.include?(phase.display_name) }.each do |phase| + puts "Removing build phase #{phase.display_name}" + phase.remove_from_project + main_target.build_phases.delete(phase) + removed_any = true + end + end + + if removed_any + project.save + puts 'Extension and watch target references removed' + else + puts 'No extension or watch targets found' + end + RUBY + + - name: Remove extension schemes + run: | + rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WidgetsExtension.xcscheme + rm -f ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme + + - name: Install CocoaPods dependencies + env: + RCT_USE_RN_DEP: "1" + RCT_USE_PREBUILT_RNCORE: "1" + USE_CCACHE: "1" + run: bundle exec fastlane ios install_pods || bundle exec fastlane ios install_pods + + - name: Reset ccache stats + run: ccache -z || true + + - name: Build iOS simulator app + working-directory: ios + env: + RCT_NO_LAUNCH_PACKAGER: "1" + CCACHE_BINARY: /opt/homebrew/bin/ccache + run: | + set -eo pipefail + build() { + xcodebuild \ + -workspace BlueWallet.xcworkspace \ + -scheme BlueWallet \ + -configuration "${BUILD_CONFIGURATION}" \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath build \ + CLANG_ENABLE_EXPLICIT_MODULES=NO \ + SWIFT_ENABLE_EXPLICIT_MODULES=NO \ + build + } + build || build + + - name: ccache stats + if: always() + run: ccache -s || true + + - name: Package simulator app + run: | + APP_DIR="ios/build/Build/Products/${BUILD_CONFIGURATION}-iphonesimulator/BlueWallet.app" + if [ ! -d "$APP_DIR" ]; then + echo "Simulator app not found at $APP_DIR" + find ios/build -maxdepth 5 -name '*.app' || true + exit 1 + fi + tar -czf BlueWallet.app.tar.gz -C "$(dirname "$APP_DIR")" "$(basename "$APP_DIR")" + + - name: Upload simulator app + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: bluewallet-ios-app + path: BlueWallet.app.tar.gz + retention-days: 3 + compression-level: 0 + if-no-files-found: error + + test: + runs-on: macos-26 + needs: build + env: + HD_MNEMONIC: ${{ secrets.HD_MNEMONIC }} + HD_MNEMONIC_BIP84: ${{ secrets.HD_MNEMONIC_BIP84 }} + + steps: + - name: Checkout + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Setup Node.js + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: 24 + cache: 'npm' + + - name: Install Node dependencies + run: npm ci || npm ci + + - name: Install applesimutils + run: | + brew tap wix/brew + brew trust wix/brew + brew install applesimutils + + - name: Download simulator app + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: bluewallet-ios-app + + - name: Restore simulator app + run: | + mkdir -p ios/build/Build/Products/Release-iphonesimulator + tar -xzf BlueWallet.app.tar.gz -C ios/build/Build/Products/Release-iphonesimulator + + # Pre-boot simulator so first detox launchApp lands warm. + - name: Pre-boot iOS simulator + run: | + DEVICE_TYPE=$(jq -r '.devices.simulator.device.type' .detoxrc.json) + UDID=$(applesimutils --list --byType "$DEVICE_TYPE" | jq -r '.[0].udid // empty') + if [ -z "$UDID" ]; then + echo "ERROR: no simulator of type '$DEVICE_TYPE' found" + exit 1 + fi + xcrun simctl boot "$UDID" 2>/dev/null || true + xcrun simctl bootstatus "$UDID" -b + xcrun simctl launch "$UDID" com.apple.springboard >/dev/null 2>&1 || true + + # Cut animations so detox sync stays steady on slow CI VMs; Reduce Motion makes reanimated skip to final value. + - name: Disable simulator animations + run: | + defaults write com.apple.iphonesimulator SlowMotionAnimation -bool NO + xcrun simctl spawn booted defaults write com.apple.Accessibility ReduceMotionEnabled -bool true + xcrun simctl spawn booted notifyutil -p com.apple.Accessibility.ReduceMotionStatusDidChange + + - name: Run detox tests + timeout-minutes: 360 + run: | + npm run e2e:test:ios-release -- \ + --record-videos failing \ + --record-logs failing \ + --take-screenshots failing \ + --headless \ + --retries 3 \ + --reuse \ + --artifacts-location ./artifacts + + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: failure() + with: + name: e2e-ios-videos + path: ./artifacts/ diff --git a/.gitignore b/.gitignore index d802dd90b55..6a5016a86db 100644 --- a/.gitignore +++ b/.gitignore @@ -17,15 +17,15 @@ xcuserdata *.xccheckout *.moved-aside DerivedData +.kotlin/ *.hmap *.ipa *.xcuserstate -ios/.xcode.env.local +**/.xcode.env.local *.hprof .cxx/ *.keystore !debug.keystore - # Android/IntelliJ # build/ @@ -33,6 +33,10 @@ build/ .gradle local.properties *.iml +reproducible-builds/build + +# testing +/coverage # node.js # @@ -57,6 +61,7 @@ buck-out/ */fastlane/Preview.html */fastlane/screenshots **/fastlane/test_output +ios/fastlane # Bundle artifact *.jsbundle @@ -67,7 +72,7 @@ release-notes.txt current-branch.json # Ruby / CocoaPods -/ios/Pods/ +**/Pods/ /vendor/bundle/ ios/BlueWallet.xcodeproj/xcuserdata/ @@ -78,7 +83,19 @@ artifacts/ # Editors .vscode/ /.vs +.claude *.mx *.realm -*.realm.lock \ No newline at end of file +*.realm.lock +android/app/.project +android/.settings/org.eclipse.buildship.core.prefs +android/app/.classpath +android/.settings/org.eclipse.buildship.core.prefs +android/.project +android/app/.settings/org.eclipse.jdt.core.prefs +android/.settings/org.eclipse.buildship.core.prefs +android/app/.classpath +android/app/.project +fastlane/README.md +fastlane/report.xml diff --git a/.ruby-version b/.ruby-version index 49cdd668e1c..84d6c676541 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -2.7.6 +3.4.10 diff --git a/.tx/config b/.tx/config index 629687828d1..85c8ba75575 100644 --- a/.tx/config +++ b/.tx/config @@ -1,37 +1,44 @@ [main] host = https://www.transifex.com +# fastlane App Store metadata. lang_map remaps Transifex language codes to the +# App Store locale folder names fastlane deliver expects under fastlane/metadata/ios//. +# All 4 resources share the same map. + [o:bluewallet:p:bluewallet-fastlane:r:ios-fastlane-metadata-en-us-description-txt--master] -file_filter = ios/fastlane/metadata//description.txt -source_file = ios/fastlane/metadata/en-US/description.txt +file_filter = fastlane/metadata/ios//description.txt +source_file = fastlane/metadata/ios/en-US/description.txt source_lang = en_US type = TXT minimum_perc = 1 -lang_map = fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, zh_CN: zh-Hans, zh_HK: zh-Hant, ar_SA: ar-SA, es_MX: es-MX, fr_CA: fr-CA, pt_PT: pt-PT, de_DE: de-DE, es_ES: es-ES +lang_map = ar_SA: ar-SA, de_DE: de-DE, es_ES: es-ES, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, pt_PT: pt-PT, zh_CN: zh-Hans, zh_HK: zh-Hant [o:bluewallet:p:bluewallet-fastlane:r:ios-fastlane-metadata-en-us-keywords-txt--master] -file_filter = ios/fastlane/metadata//keywords.txt -source_file = ios/fastlane/metadata/en-US/keywords.txt +file_filter = fastlane/metadata/ios//keywords.txt +source_file = fastlane/metadata/ios/en-US/keywords.txt source_lang = en_US type = TXT minimum_perc = 1 -lang_map = es_ES: es-ES, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, zh_CN: zh-Hans, ar_SA: ar-SA, de_DE: de-DE, pt_PT: pt-PT, zh_HK: zh-Hant +lang_map = ar_SA: ar-SA, de_DE: de-DE, es_ES: es-ES, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, pt_PT: pt-PT, zh_CN: zh-Hans, zh_HK: zh-Hant [o:bluewallet:p:bluewallet-fastlane:r:ios-fastlane-metadata-en-us-name-txt--master] -file_filter = ios/fastlane/metadata//name.txt -source_file = ios/fastlane/metadata/en-US/name.txt +file_filter = fastlane/metadata/ios//name.txt +source_file = fastlane/metadata/ios/en-US/name.txt source_lang = en_US type = TXT minimum_perc = 1 -lang_map = zh_HK: zh-Hant, ar_SA: ar-SA, es_MX: es-MX, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, pt_PT: pt-PT, zh_CN: zh-Hans, de_DE: de-DE, es_ES: es-ES, fr_CA: fr-CA +lang_map = ar_SA: ar-SA, de_DE: de-DE, es_ES: es-ES, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, pt_PT: pt-PT, zh_CN: zh-Hans, zh_HK: zh-Hant [o:bluewallet:p:bluewallet-fastlane:r:ios-fastlane-metadata-en-us-promotional-text-txt--master] -file_filter = ios/fastlane/metadata//promotional_text.txt -source_file = ios/fastlane/metadata/en-US/promotional_text.txt +file_filter = fastlane/metadata/ios//promotional_text.txt +source_file = fastlane/metadata/ios/en-US/promotional_text.txt source_lang = en_US type = TXT minimum_perc = 1 -lang_map = zh_CN: zh-Hans, zh_HK: zh-Hant, ar_SA: ar-SA, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, pt_PT: pt-PT, de_DE: de-DE, es_ES: es-ES, nl_NL: nl-NL, pt_BR: pt-BR +lang_map = ar_SA: ar-SA, de_DE: de-DE, es_ES: es-ES, es_MX: es-MX, fr_CA: fr-CA, fr_FR: fr-FR, nl_NL: nl-NL, pt_BR: pt-BR, pt_PT: pt-PT, zh_CN: zh-Hans, zh_HK: zh-Hant + +# App UI strings. lang_map remaps Transifex language codes to the loc/.json +# filenames the app loads (separate, larger map than the fastlane resources above). [o:bluewallet:p:bluewallet:r:loc-en-json--master] file_filter = loc/.json diff --git a/App.js b/App.js deleted file mode 100644 index 9051c159d6c..00000000000 --- a/App.js +++ /dev/null @@ -1,396 +0,0 @@ -import 'react-native-gesture-handler'; // should be on top -import React, { useContext, useEffect, useRef } from 'react'; -import { - AppState, - DeviceEventEmitter, - NativeModules, - NativeEventEmitter, - Linking, - Platform, - StyleSheet, - UIManager, - useColorScheme, - View, - StatusBar, - LogBox, -} from 'react-native'; -import { NavigationContainer, CommonActions } from '@react-navigation/native'; -import { SafeAreaProvider } from 'react-native-safe-area-context'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; - -import { navigationRef } from './NavigationService'; -import * as NavigationService from './NavigationService'; -import { Chain } from './models/bitcoinUnits'; -import OnAppLaunch from './class/on-app-launch'; -import DeeplinkSchemaMatch from './class/deeplink-schema-match'; -import loc from './loc'; -import { BlueDefaultTheme, BlueDarkTheme } from './components/themes'; -import InitRoot from './Navigation'; -import BlueClipboard from './blue_modules/clipboard'; -import { isDesktop } from './blue_modules/environment'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import WatchConnectivity from './WatchConnectivity'; -import DeviceQuickActions from './class/quick-actions'; -import Notifications from './blue_modules/notifications'; -import Biometric from './class/biometrics'; -import WidgetCommunication from './blue_modules/WidgetCommunication'; -import changeNavigationBarColor from 'react-native-navigation-bar-color'; -import ActionSheet from './screen/ActionSheet'; -import HandoffComponent from './components/handoff'; -import Privacy from './blue_modules/Privacy'; -const A = require('./blue_modules/analytics'); -const currency = require('./blue_modules/currency'); - -const eventEmitter = Platform.OS === 'ios' ? new NativeEventEmitter(NativeModules.EventEmitter) : undefined; -const { EventEmitter } = NativeModules; - -LogBox.ignoreLogs(['Require cycle:']); - -const ClipboardContentType = Object.freeze({ - BITCOIN: 'BITCOIN', - LIGHTNING: 'LIGHTNING', -}); - -if (Platform.OS === 'android') { - if (UIManager.setLayoutAnimationEnabledExperimental) { - UIManager.setLayoutAnimationEnabledExperimental(true); - } -} - -const App = () => { - const { walletsInitialized, wallets, addWallet, saveToDisk, fetchAndSaveWalletTransactions, refreshAllWalletTransactions } = - useContext(BlueStorageContext); - const appState = useRef(AppState.currentState); - const clipboardContent = useRef(); - const colorScheme = useColorScheme(); - - const onNotificationReceived = async notification => { - const payload = Object.assign({}, notification, notification.data); - if (notification.data && notification.data.data) Object.assign(payload, notification.data.data); - payload.foreground = true; - - await Notifications.addNotification(payload); - // if user is staring at the app when he receives the notification we process it instantly - // so app refetches related wallet - if (payload.foreground) await processPushNotifications(); - }; - - const openSettings = () => { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'Settings', - }), - ); - }; - - const onUserActivityOpen = data => { - switch (data.activityType) { - case HandoffComponent.activityTypes.ReceiveOnchain: - NavigationService.navigate('ReceiveDetailsRoot', { - screen: 'ReceiveDetails', - params: { - address: data.userInfo.address, - }, - }); - break; - case HandoffComponent.activityTypes.Xpub: - NavigationService.navigate('WalletXpubRoot', { - screen: 'WalletXpub', - params: { - xpub: data.userInfo.xpub, - }, - }); - break; - default: - break; - } - }; - - useEffect(() => { - if (walletsInitialized) { - addListeners(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized]); - - useEffect(() => { - return () => { - Linking.removeEventListener('url', handleOpenURL); - AppState.removeEventListener('change', handleAppStateChange); - eventEmitter?.removeAllListeners('onNotificationReceived'); - eventEmitter?.removeAllListeners('openSettings'); - eventEmitter?.removeAllListeners('onUserActivityOpen'); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - useEffect(() => { - if (colorScheme) { - if (colorScheme === 'light') { - changeNavigationBarColor(BlueDefaultTheme.colors.background, true, true); - } else { - changeNavigationBarColor(BlueDarkTheme.colors.buttonBackgroundColor, false, true); - } - } - }, [colorScheme]); - - const addListeners = () => { - Linking.addEventListener('url', handleOpenURL); - AppState.addEventListener('change', handleAppStateChange); - DeviceEventEmitter.addListener('quickActionShortcut', walletQuickActions); - DeviceQuickActions.popInitialAction().then(popInitialAction); - EventEmitter?.getMostRecentUserActivity() - .then(onUserActivityOpen) - .catch(() => console.log('No userActivity object sent')); - handleAppStateChange(undefined); - /* - When a notification on iOS is shown while the app is on foreground; - On willPresent on AppDelegate.m - */ - eventEmitter?.addListener('onNotificationReceived', onNotificationReceived); - eventEmitter?.addListener('openSettings', openSettings); - eventEmitter?.addListener('onUserActivityOpen', onUserActivityOpen); - }; - - const popInitialAction = async data => { - if (data) { - const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - } else { - const url = await Linking.getInitialURL(); - if (url) { - if (DeeplinkSchemaMatch.hasSchema(url)) { - handleOpenURL({ url }); - } - } else { - const isViewAllWalletsEnabled = await OnAppLaunch.isViewAllWalletsEnabled(); - if (!isViewAllWalletsEnabled) { - const selectedDefaultWallet = await OnAppLaunch.getSelectedDefaultWallet(); - const wallet = wallets.find(w => w.getID() === selectedDefaultWallet.getID()); - if (wallet) { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - } - } - } - } - }; - - const walletQuickActions = data => { - const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID: wallet.getID(), - walletType: wallet.type, - }, - }), - ); - }; - - /** - * Processes push notifications stored in AsyncStorage. Might navigate to some screen. - * - * @returns {Promise} returns TRUE if notification was processed _and acted_ upon, i.e. navigation happened - * @private - */ - const processPushNotifications = async () => { - if (!walletsInitialized) { - console.log('not processing push notifications because wallets are not initialized'); - return; - } - await new Promise(resolve => setTimeout(resolve, 200)); - // sleep needed as sometimes unsuspend is faster than notification module actually saves notifications to async storage - const notifications2process = await Notifications.getStoredNotifications(); - - await Notifications.clearStoredNotifications(); - Notifications.setApplicationIconBadgeNumber(0); - const deliveredNotifications = await Notifications.getDeliveredNotifications(); - setTimeout(() => Notifications.removeAllDeliveredNotifications(), 5000); // so notification bubble wont disappear too fast - - for (const payload of notifications2process) { - const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); - - console.log('processing push notification:', payload); - let wallet; - switch (+payload.type) { - case 2: - case 3: - wallet = wallets.find(w => w.weOwnAddress(payload.address)); - break; - case 1: - case 4: - wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); - break; - } - - if (wallet) { - const walletID = wallet.getID(); - fetchAndSaveWalletTransactions(walletID); - if (wasTapped) { - if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { - NavigationService.dispatch( - CommonActions.navigate({ - name: 'WalletTransactions', - key: `WalletTransactions-${wallet.getID()}`, - params: { - walletID, - walletType: wallet.type, - }, - }), - ); - } else { - NavigationService.navigate('ReceiveDetailsRoot', { - screen: 'ReceiveDetails', - params: { - walletID, - address: payload.address, - }, - }); - } - - return true; - } - } else { - console.log('could not find wallet while processing push notification, NOP'); - } - } // end foreach notifications loop - - if (deliveredNotifications.length > 0) { - // notification object is missing userInfo. We know we received a notification but don't have sufficient - // data to refresh 1 wallet. let's refresh all. - refreshAllWalletTransactions(); - } - - // if we are here - we did not act upon any push - return false; - }; - - const handleAppStateChange = async nextAppState => { - if (wallets.length === 0) return; - if ((appState.current.match(/background/) && nextAppState === 'active') || nextAppState === undefined) { - setTimeout(() => A(A.ENUM.APP_UNSUSPENDED), 2000); - currency.updateExchangeRate(); - const processed = await processPushNotifications(); - if (processed) return; - const clipboard = await BlueClipboard().getClipboardContent(); - const isAddressFromStoredWallet = wallets.some(wallet => { - if (wallet.chain === Chain.ONCHAIN) { - // checking address validity is faster than unwrapping hierarchy only to compare it to garbage - return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); - } else { - return wallet.isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); - } - }); - const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); - const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); - const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); - const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); - if ( - !isAddressFromStoredWallet && - clipboardContent.current !== clipboard && - (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) - ) { - let contentType; - if (isBitcoinAddress) { - contentType = ClipboardContentType.BITCOIN; - } else if (isLightningInvoice || isLNURL) { - contentType = ClipboardContentType.LIGHTNING; - } else if (isBothBitcoinAndLightning) { - contentType = ClipboardContentType.BITCOIN; - } - showClipboardAlert({ contentType }); - } - clipboardContent.current = clipboard; - } - if (nextAppState) { - appState.current = nextAppState; - } - }; - - const handleOpenURL = event => { - DeeplinkSchemaMatch.navigationRouteFor(event, value => NavigationService.navigate(...value), { wallets, addWallet, saveToDisk }); - }; - - const showClipboardAlert = ({ contentType }) => { - ReactNativeHapticFeedback.trigger('impactLight', { ignoreAndroidSystemSettings: false }); - BlueClipboard() - .getClipboardContent() - .then(clipboard => { - if (Platform.OS === 'ios' || Platform.OS === 'macos') { - ActionSheet.showActionSheetWithOptions( - { - options: [loc._.cancel, loc._.continue], - title: loc._.clipboard, - message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, - cancelButtonIndex: 0, - }, - buttonIndex => { - if (buttonIndex === 1) { - handleOpenURL({ url: clipboard }); - } - }, - ); - } else { - ActionSheet.showActionSheetWithOptions({ - buttons: [ - { text: loc._.cancel, style: 'cancel', onPress: () => {} }, - { - text: loc._.continue, - style: 'default', - onPress: () => { - handleOpenURL({ url: clipboard }); - }, - }, - ], - title: loc._.clipboard, - message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, - }); - } - }); - }; - - return ( - - - - - - - - {walletsInitialized && !isDesktop && } - - - - - - - ); -}; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, -}); - -export default App; diff --git a/App.tsx b/App.tsx new file mode 100644 index 00000000000..b1725e92db8 --- /dev/null +++ b/App.tsx @@ -0,0 +1,105 @@ +import { CommonActions, NavigationAction, NavigationContainer, NavigationContainerRef, ParamListBase } from '@react-navigation/native'; +import React, { useCallback } from 'react'; +import { useColorScheme } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { SizeClassProvider } from './components/Context/SizeClassProvider'; +import { SettingsProvider } from './components/Context/SettingsProvider'; +import { BlueDarkTheme, BlueDefaultTheme } from './components/themes'; +import MasterView from './navigation/MasterView'; +import { navigationRef } from './NavigationService'; +import { useLogger } from '@react-navigation/devtools'; +import { StorageProvider } from './components/Context/StorageProvider'; +import { useStorage } from './hooks/context/useStorage'; +import { unlockWithBiometrics, useBiometrics } from './hooks/useBiometrics'; +import { presentWalletExportReminder } from './helpers/presentWalletExportReminder'; +import { requestCameraAuthorization } from './helpers/scan-qr'; +import { + findNavigatorKeyForRoute, + getGuardedRoute, + GuardedRoute, + GuardedNavigationAction, + validateGuardedRoute, +} from './navigation/navigationGuard'; + +const Navigation = ({ colorScheme }: { colorScheme: ReturnType }) => { + const { wallets, saveToDisk } = useStorage(); + const { isBiometricUseEnabled } = useBiometrics(); + + const validateNavigation = useCallback( + (route: GuardedRoute) => + validateGuardedRoute(route, { + currentRouteName: navigationRef.getCurrentRoute()?.name, + isBiometricUseEnabled, + unlockWithBiometrics, + wallets, + saveToDisk, + presentWalletExportReminder, + requestCameraAuthorization, + }), + [isBiometricUseEnabled, saveToDisk, wallets], + ); + + const handleUnhandledAction = useCallback( + (action: Readonly) => { + const guardedRoute = getGuardedRoute(action); + if (!guardedRoute) { + console.error('Unhandled navigation action', action); + return; + } + + const actionRouteName = + action.payload && 'name' in action.payload && typeof action.payload.name === 'string' ? action.payload.name : undefined; + const navigatorKey = + actionRouteName === guardedRoute.name ? findNavigatorKeyForRoute(navigationRef.getRootState(), guardedRoute.name) : undefined; + + validateNavigation(guardedRoute) + .then(result => { + if (!result.allowed && !result.redirect) return; + + const nextAction = result.redirect ? CommonActions.navigate(result.redirect.name, result.redirect.params) : action; + const targetRouteName = result.redirect?.name ?? guardedRoute.name; + const targetNavigatorKey = result.redirect + ? findNavigatorKeyForRoute(navigationRef.getRootState(), targetRouteName) + : navigatorKey; + + navigationRef.dispatch({ + ...nextAction, + ...(targetNavigatorKey ? { target: targetNavigatorKey } : {}), + navigationGuardValidated: true, + } as GuardedNavigationAction); + }) + .catch(error => console.error('Navigation validation failed', error)); + }, + [validateNavigation], + ); + + useLogger(navigationRef as unknown as React.RefObject>); + + return ( + + + + ); +}; + +const App = () => { + const colorScheme = useColorScheme(); + + return ( + + + + + + + + + + ); +}; + +export default App; diff --git a/BlueApp.js b/BlueApp.js deleted file mode 100644 index c0ee48a831d..00000000000 --- a/BlueApp.js +++ /dev/null @@ -1,944 +0,0 @@ -import Biometric from './class/biometrics'; -import { Platform } from 'react-native'; -import loc from './loc'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import RNSecureKeyStore, { ACCESSIBLE } from 'react-native-secure-key-store'; -import * as Keychain from 'react-native-keychain'; -import { - HDLegacyBreadwalletWallet, - HDSegwitP2SHWallet, - HDLegacyP2PKHWallet, - WatchOnlyWallet, - LegacyWallet, - SegwitP2SHWallet, - SegwitBech32Wallet, - HDSegwitBech32Wallet, - LightningCustodianWallet, - HDLegacyElectrumSeedP2PKHWallet, - HDSegwitElectrumSeedP2WPKHWallet, - HDAezeedWallet, - MultisigHDWallet, - LightningLdkWallet, - SLIP39SegwitP2SHWallet, - SLIP39LegacyP2PKHWallet, - SLIP39SegwitBech32Wallet, -} from './class/'; -import { randomBytes } from './class/rng'; -import alert from './components/Alert'; - -const encryption = require('./blue_modules/encryption'); -const Realm = require('realm'); -const createHash = require('create-hash'); -let usedBucketNum = false; -let savingInProgress = 0; // its both a flag and a counter of attempts to write to disk -const prompt = require('./helpers/prompt'); -const currency = require('./blue_modules/currency'); -const BlueElectrum = require('./blue_modules/BlueElectrum'); -BlueElectrum.connectMain(); - -class AppStorage { - static FLAG_ENCRYPTED = 'data_encrypted'; - static LNDHUB = 'lndhub'; - static ADVANCED_MODE_ENABLED = 'advancedmodeenabled'; - static DO_NOT_TRACK = 'donottrack'; - static HANDOFF_STORAGE_KEY = 'HandOff'; - - static keys2migrate = [AppStorage.HANDOFF_STORAGE_KEY, AppStorage.DO_NOT_TRACK, AppStorage.ADVANCED_MODE_ENABLED]; - - constructor() { - /** {Array.} */ - this.wallets = []; - this.tx_metadata = {}; - this.cachedPassword = false; - } - - async migrateKeys() { - if (!(typeof navigator !== 'undefined' && navigator.product === 'ReactNative')) return; - for (const key of this.constructor.keys2migrate) { - try { - const value = await RNSecureKeyStore.get(key); - if (value) { - await AsyncStorage.setItem(key, value); - await RNSecureKeyStore.remove(key); - } - } catch (_) {} - } - } - - /** - * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is - * used for cli/tests - * - * @param key - * @param value - * @returns {Promise|Promise | Promise | * | Promise | void} - */ - setItem = (key, value) => { - if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { - return RNSecureKeyStore.set(key, value, { accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); - } else { - return AsyncStorage.setItem(key, value); - } - }; - - /** - * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is - * used for cli/tests - * - * @param key - * @returns {Promise|*} - */ - getItem = key => { - if (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') { - return RNSecureKeyStore.get(key); - } else { - return AsyncStorage.getItem(key); - } - }; - - /** - * @throws Error - * @param key {string} - * @returns {Promise<*>|null} - */ - getItemWithFallbackToRealm = async key => { - let value; - try { - return await this.getItem(key); - } catch (error) { - console.warn('error reading', key, error.message); - console.warn('fallback to realm'); - const realmKeyValue = await this.openRealmKeyValue(); - const obj = realmKeyValue.objectForPrimaryKey('KeyValue', key); // search for a realm object with a primary key - value = obj?.value; - realmKeyValue.close(); - if (value) { - console.warn('successfully recovered', value.length, 'bytes from realm for key', key); - return value; - } - return null; - } - }; - - storageIsEncrypted = async () => { - let data; - try { - data = await this.getItemWithFallbackToRealm(AppStorage.FLAG_ENCRYPTED); - } catch (error) { - console.warn('error reading `' + AppStorage.FLAG_ENCRYPTED + '` key:', error.message); - return false; - } - - return !!data; - }; - - isPasswordInUse = async password => { - try { - let data = await this.getItem('data'); - data = this.decryptData(data, password); - return !!data; - } catch (_e) { - return false; - } - }; - - /** - * Iterates through all values of `data` trying to - * decrypt each one, and returns first one successfully decrypted - * - * @param data {string} Serialized array - * @param password - * @returns {boolean|string} Either STRING of storage data (which is stringified JSON) or FALSE, which means failure - */ - decryptData(data, password) { - data = JSON.parse(data); - let decrypted; - let num = 0; - for (const value of data) { - decrypted = encryption.decrypt(value, password); - - if (decrypted) { - usedBucketNum = num; - return decrypted; - } - num++; - } - - return false; - } - - decryptStorage = async password => { - if (password === this.cachedPassword) { - this.cachedPassword = undefined; - await this.saveToDisk(); - this.wallets = []; - this.tx_metadata = []; - return this.loadFromDisk(); - } else { - throw new Error('Incorrect password. Please, try again.'); - } - }; - - encryptStorage = async password => { - // assuming the storage is not yet encrypted - await this.saveToDisk(); - let data = await this.getItem('data'); - // TODO: refactor ^^^ (should not save & load to fetch data) - - const encrypted = encryption.encrypt(data, password); - data = []; - data.push(encrypted); // putting in array as we might have many buckets with storages - data = JSON.stringify(data); - this.cachedPassword = password; - await this.setItem('data', data); - await this.setItem(AppStorage.FLAG_ENCRYPTED, '1'); - }; - - /** - * Cleans up all current application data (wallets, tx metadata etc) - * Encrypts the bucket and saves it storage - * - * @returns {Promise.} Success or failure - */ - createFakeStorage = async fakePassword => { - usedBucketNum = false; // resetting currently used bucket so we wont overwrite it - this.wallets = []; - this.tx_metadata = {}; - - const data = { - wallets: [], - tx_metadata: {}, - }; - - let buckets = await this.getItem('data'); - buckets = JSON.parse(buckets); - buckets.push(encryption.encrypt(JSON.stringify(data), fakePassword)); - this.cachedPassword = fakePassword; - const bucketsString = JSON.stringify(buckets); - await this.setItem('data', bucketsString); - return (await this.getItem('data')) === bucketsString; - }; - - hashIt = s => { - return createHash('sha256').update(s).digest().toString('hex'); - }; - - /** - * Returns instace of the Realm database, which is encrypted either by cached user's password OR default password. - * Database file is deterministically derived from encryption key. - * - * @returns {Promise} - */ - async getRealm() { - const password = this.hashIt(this.cachedPassword || 'fyegjitkyf[eqjnc.lf'); - const buf = Buffer.from(this.hashIt(password) + this.hashIt(password), 'hex'); - const encryptionKey = Int8Array.from(buf); - const path = this.hashIt(this.hashIt(password)) + '-wallettransactions.realm'; - - const schema = [ - { - name: 'WalletTransactions', - properties: { - walletid: { type: 'string', indexed: true }, - internal: 'bool?', // true - internal, false - external - index: 'int?', - tx: 'string', // stringified json - }, - }, - ]; - return Realm.open({ - schema, - path, - encryptionKey, - }); - } - - /** - * Returns instace of the Realm database, which is encrypted by device unique id - * Database file is static. - * - * @returns {Promise} - */ - async openRealmKeyValue() { - const service = 'realm_encryption_key'; - let password; - const credentials = await Keychain.getGenericPassword({ service }); - if (credentials) { - password = credentials.password; - } else { - const buf = await randomBytes(64); - password = buf.toString('hex'); - await Keychain.setGenericPassword(service, password, { service }); - } - - const buf = Buffer.from(password, 'hex'); - const encryptionKey = Int8Array.from(buf); - const path = 'keyvalue.realm'; - - const schema = [ - { - name: 'KeyValue', - primaryKey: 'key', - properties: { - key: { type: 'string', indexed: true }, - value: 'string', // stringified json, or whatever - }, - }, - ]; - return Realm.open({ - schema, - path, - encryptionKey, - }); - } - - saveToRealmKeyValue(realmkeyValue, key, value) { - realmkeyValue.write(() => { - realmkeyValue.create( - 'KeyValue', - { - key, - value, - }, - Realm.UpdateMode.Modified, - ); - }); - } - - /** - * Loads from storage all wallets and - * maps them to `this.wallets` - * - * @param password If present means storage must be decrypted before usage - * @returns {Promise.} - */ - async loadFromDisk(password) { - let data = await this.getItemWithFallbackToRealm('data'); - if (password) { - data = this.decryptData(data, password); - if (data) { - // password is good, cache it - this.cachedPassword = password; - } - } - if (data !== null) { - let realm; - try { - realm = await this.getRealm(); - } catch (error) { - alert(error.message); - } - data = JSON.parse(data); - if (!data.wallets) return false; - const wallets = data.wallets; - for (const key of wallets) { - // deciding which type is wallet and instatiating correct object - const tempObj = JSON.parse(key); - let unserializedWallet; - switch (tempObj.type) { - case SegwitBech32Wallet.type: - unserializedWallet = SegwitBech32Wallet.fromJson(key); - break; - case SegwitP2SHWallet.type: - unserializedWallet = SegwitP2SHWallet.fromJson(key); - break; - case WatchOnlyWallet.type: - unserializedWallet = WatchOnlyWallet.fromJson(key); - unserializedWallet.init(); - if (unserializedWallet.isHd() && !unserializedWallet.isXpubValid()) { - continue; - } - break; - case HDLegacyP2PKHWallet.type: - unserializedWallet = HDLegacyP2PKHWallet.fromJson(key); - break; - case HDSegwitP2SHWallet.type: - unserializedWallet = HDSegwitP2SHWallet.fromJson(key); - break; - case HDSegwitBech32Wallet.type: - unserializedWallet = HDSegwitBech32Wallet.fromJson(key); - break; - case HDLegacyBreadwalletWallet.type: - unserializedWallet = HDLegacyBreadwalletWallet.fromJson(key); - break; - case HDLegacyElectrumSeedP2PKHWallet.type: - unserializedWallet = HDLegacyElectrumSeedP2PKHWallet.fromJson(key); - break; - case HDSegwitElectrumSeedP2WPKHWallet.type: - unserializedWallet = HDSegwitElectrumSeedP2WPKHWallet.fromJson(key); - break; - case MultisigHDWallet.type: - unserializedWallet = MultisigHDWallet.fromJson(key); - break; - case HDAezeedWallet.type: - unserializedWallet = HDAezeedWallet.fromJson(key); - // migrate password to this.passphrase field - // remove this code somewhere in year 2022 - if (unserializedWallet.secret.includes(':')) { - const [mnemonic, passphrase] = unserializedWallet.secret.split(':'); - unserializedWallet.secret = mnemonic; - unserializedWallet.passphrase = passphrase; - } - - break; - case LightningLdkWallet.type: - unserializedWallet = LightningLdkWallet.fromJson(key); - break; - case SLIP39SegwitP2SHWallet.type: - unserializedWallet = SLIP39SegwitP2SHWallet.fromJson(key); - break; - case SLIP39LegacyP2PKHWallet.type: - unserializedWallet = SLIP39LegacyP2PKHWallet.fromJson(key); - break; - case SLIP39SegwitBech32Wallet.type: - unserializedWallet = SLIP39SegwitBech32Wallet.fromJson(key); - break; - case LightningCustodianWallet.type: { - /** @type {LightningCustodianWallet} */ - unserializedWallet = LightningCustodianWallet.fromJson(key); - let lndhub = false; - try { - lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); - } catch (error) { - console.warn(error); - } - - if (unserializedWallet.baseURI) { - unserializedWallet.setBaseURI(unserializedWallet.baseURI); // not really necessary, just for the sake of readability - console.log('using saved uri for for ln wallet:', unserializedWallet.baseURI); - } else if (lndhub) { - console.log('using wallet-wide settings ', lndhub, 'for ln wallet'); - unserializedWallet.setBaseURI(lndhub); - } else { - console.log('wallet does not have a baseURI. Continuing init...'); - } - unserializedWallet.init(); - break; - } - case LegacyWallet.type: - default: - unserializedWallet = LegacyWallet.fromJson(key); - break; - } - - try { - if (realm) this.inflateWalletFromRealm(realm, unserializedWallet); - } catch (error) { - alert(error.message); - } - - // done - const ID = unserializedWallet.getID(); - if (!this.wallets.some(wallet => wallet.getID() === ID)) { - this.wallets.push(unserializedWallet); - this.tx_metadata = data.tx_metadata; - } - } - if (realm) realm.close(); - return true; - } else { - return false; // failed loading data or loading/decryptin data - } - } - - /** - * Lookup wallet in list by it's secret and - * remove it from `this.wallets` - * - * @param wallet {AbstractWallet} - */ - deleteWallet = wallet => { - const ID = wallet.getID(); - const tempWallets = []; - - if (wallet.type === LightningLdkWallet.type) { - /** @type {LightningLdkWallet} */ - const ldkwallet = wallet; - ldkwallet.stop().then(ldkwallet.purgeLocalStorage).catch(alert); - } - - for (const value of this.wallets) { - if (value.getID() === ID) { - // the one we should delete - // nop - } else { - // the one we must keep - tempWallets.push(value); - } - } - this.wallets = tempWallets; - }; - - inflateWalletFromRealm(realm, walletToInflate) { - const transactions = realm.objects('WalletTransactions'); - const transactionsForWallet = transactions.filtered(`walletid = "${walletToInflate.getID()}"`); - for (const tx of transactionsForWallet) { - if (tx.internal === false) { - if (walletToInflate._hdWalletInstance) { - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index] = - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index] || []; - walletToInflate._hdWalletInstance._txs_by_external_index[tx.index].push(JSON.parse(tx.tx)); - } else { - walletToInflate._txs_by_external_index[tx.index] = walletToInflate._txs_by_external_index[tx.index] || []; - walletToInflate._txs_by_external_index[tx.index].push(JSON.parse(tx.tx)); - } - } else if (tx.internal === true) { - if (walletToInflate._hdWalletInstance) { - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index] = - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index] || []; - walletToInflate._hdWalletInstance._txs_by_internal_index[tx.index].push(JSON.parse(tx.tx)); - } else { - walletToInflate._txs_by_internal_index[tx.index] = walletToInflate._txs_by_internal_index[tx.index] || []; - walletToInflate._txs_by_internal_index[tx.index].push(JSON.parse(tx.tx)); - } - } else { - if (!Array.isArray(walletToInflate._txs_by_external_index)) walletToInflate._txs_by_external_index = []; - walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || []; - walletToInflate._txs_by_external_index.push(JSON.parse(tx.tx)); - } - } - } - - offloadWalletToRealm(realm, wallet) { - const id = wallet.getID(); - const walletToSave = wallet._hdWalletInstance ?? wallet; - - if (Array.isArray(walletToSave._txs_by_external_index)) { - // if this var is an array that means its a single-address wallet class, and this var is a flat array - // with transactions - realm.write(() => { - // cleanup all existing transactions for the wallet first - const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`); - realm.delete(walletTransactionsToDelete); - - for (const tx of walletToSave._txs_by_external_index) { - realm.create( - 'WalletTransactions', - { - walletid: id, - tx: JSON.stringify(tx), - }, - Realm.UpdateMode.Modified, - ); - } - }); - - return; - } - - /// ######################################################################################################## - - if (walletToSave._txs_by_external_index) { - realm.write(() => { - // cleanup all existing transactions for the wallet first - const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`); - realm.delete(walletTransactionsToDelete); - - // insert new ones: - for (const index of Object.keys(walletToSave._txs_by_external_index)) { - const txs = walletToSave._txs_by_external_index[index]; - for (const tx of txs) { - realm.create( - 'WalletTransactions', - { - walletid: id, - internal: false, - index: parseInt(index, 10), - tx: JSON.stringify(tx), - }, - Realm.UpdateMode.Modified, - ); - } - } - - for (const index of Object.keys(walletToSave._txs_by_internal_index)) { - const txs = walletToSave._txs_by_internal_index[index]; - for (const tx of txs) { - realm.create( - 'WalletTransactions', - { - walletid: id, - internal: true, - index: parseInt(index, 10), - tx: JSON.stringify(tx), - }, - Realm.UpdateMode.Modified, - ); - } - } - }); - } - } - - /** - * Serializes and saves to storage object data. - * If cached password is saved - finds the correct bucket - * to save to, encrypts and then saves. - * - * @returns {Promise} Result of storage save - */ - async saveToDisk() { - if (savingInProgress) { - console.warn('saveToDisk is in progress'); - if (++savingInProgress > 10) alert('Critical error. Last actions were not saved'); // should never happen - await new Promise(resolve => setTimeout(resolve, 1000 * savingInProgress)); // sleep - return this.saveToDisk(); - } - savingInProgress = 1; - - try { - const walletsToSave = []; - let realm; - try { - realm = await this.getRealm(); - } catch (error) { - alert(error.message); - } - for (const key of this.wallets) { - if (typeof key === 'boolean') continue; - key.prepareForSerialization(); - delete key.current; - const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore - if (key._hdWalletInstance) keyCloned._hdWalletInstance = Object.assign({}, key._hdWalletInstance); - if (realm) this.offloadWalletToRealm(realm, key); - // stripping down: - if (key._txs_by_external_index) { - keyCloned._txs_by_external_index = {}; - keyCloned._txs_by_internal_index = {}; - } - if (key._hdWalletInstance) { - keyCloned._hdWalletInstance._txs_by_external_index = {}; - keyCloned._hdWalletInstance._txs_by_internal_index = {}; - } - - if (keyCloned._bip47_instance) { - delete keyCloned._bip47_instance; // since it wont be restored into a proper class instance - } - - walletsToSave.push(JSON.stringify({ ...keyCloned, type: keyCloned.type })); - } - if (realm) realm.close(); - let data = { - wallets: walletsToSave, - tx_metadata: this.tx_metadata, - }; - - if (this.cachedPassword) { - // should find the correct bucket, encrypt and then save - let buckets = await this.getItemWithFallbackToRealm('data'); - buckets = JSON.parse(buckets); - const newData = []; - let num = 0; - for (const bucket of buckets) { - let decrypted; - // if we had `usedBucketNum` during loadFromDisk(), no point to try to decode each bucket to find the one we - // need, we just to find bucket with the same index - if (usedBucketNum !== false) { - if (num === usedBucketNum) { - decrypted = true; - } - num++; - } else { - // we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket - // till we find the right one - decrypted = encryption.decrypt(bucket, this.cachedPassword); - } - - if (!decrypted) { - // no luck decrypting, its not our bucket - newData.push(bucket); - } else { - // decrypted ok, this is our bucket - // we serialize our object's data, encrypt it, and add it to buckets - newData.push(encryption.encrypt(JSON.stringify(data), this.cachedPassword)); - } - } - data = newData; - } - - await this.setItem('data', JSON.stringify(data)); - await this.setItem(AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); - - // now, backing up same data in realm: - const realmkeyValue = await this.openRealmKeyValue(); - this.saveToRealmKeyValue(realmkeyValue, 'data', JSON.stringify(data)); - this.saveToRealmKeyValue(realmkeyValue, AppStorage.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); - realmkeyValue.close(); - } catch (error) { - console.error('save to disk exception:', error.message); - alert('save to disk exception: ' + error.message); - if (error.message.includes('Realm file decryption failed')) { - console.warn('purging realm key-value database file'); - this.purgeRealmKeyValueFile(); - } - } finally { - savingInProgress = 0; - } - } - - /** - * For each wallet, fetches balance from remote endpoint. - * Use getter for a specific wallet to get actual balance. - * Returns void. - * If index is present then fetch only from this specific wallet - * - * @return {Promise.} - */ - fetchWalletBalances = async index => { - console.log('fetchWalletBalances for wallet#', typeof index === 'undefined' ? '(all)' : index); - if (index || index === 0) { - let c = 0; - for (const wallet of this.wallets) { - if (c++ === index) { - await wallet.fetchBalance(); - } - } - } else { - for (const wallet of this.wallets) { - console.log('fetching balance for', wallet.getLabel()); - await wallet.fetchBalance(); - } - } - }; - - /** - * Fetches from remote endpoint all transactions for each wallet. - * Returns void. - * To access transactions - get them from each respective wallet. - * If index is present then fetch only from this specific wallet. - * - * @param index {Integer} Index of the wallet in this.wallets array, - * blank to fetch from all wallets - * @return {Promise.} - */ - fetchWalletTransactions = async index => { - console.log('fetchWalletTransactions for wallet#', typeof index === 'undefined' ? '(all)' : index); - if (index || index === 0) { - let c = 0; - for (const wallet of this.wallets) { - if (c++ === index) { - await wallet.fetchTransactions(); - if (wallet.fetchPendingTransactions) { - await wallet.fetchPendingTransactions(); - } - if (wallet.fetchUserInvoices) { - await wallet.fetchUserInvoices(); - } - } - } - } else { - for (const wallet of this.wallets) { - await wallet.fetchTransactions(); - if (wallet.fetchPendingTransactions) { - await wallet.fetchPendingTransactions(); - } - if (wallet.fetchUserInvoices) { - await wallet.fetchUserInvoices(); - } - } - } - }; - - fetchSenderPaymentCodes = async index => { - console.log('fetchSenderPaymentCodes for wallet#', typeof index === 'undefined' ? '(all)' : index); - if (index || index === 0) { - try { - if (!(this.wallets[index].allowBIP47() && this.wallets[index].isBIP47Enabled())) return; - await this.wallets[index].fetchBIP47SenderPaymentCodes(); - } catch (error) { - console.error('Failed to fetch sender payment codes for wallet', index, error); - } - } else { - for (const wallet of this.wallets) { - try { - if (!(wallet.allowBIP47() && wallet.isBIP47Enabled())) continue; - await wallet.fetchBIP47SenderPaymentCodes(); - } catch (error) { - console.error('Failed to fetch sender payment codes for wallet', wallet.label, error); - } - } - } - }; - - /** - * - * @returns {Array.} - */ - getWallets = () => { - return this.wallets; - }; - - /** - * Getter for all transactions in all wallets. - * But if index is provided - only for wallet with corresponding index - * - * @param index {Integer|null} Wallet index in this.wallets. Empty (or null) for all wallets. - * @param limit {Integer} How many txs return, starting from the earliest. Default: all of them. - * @param includeWalletsWithHideTransactionsEnabled {Boolean} Wallets' _hideTransactionsInWalletsList property determines wether the user wants this wallet's txs hidden from the main list view. - * @return {Array} - */ - getTransactions = (index, limit = Infinity, includeWalletsWithHideTransactionsEnabled = false) => { - if (index || index === 0) { - let txs = []; - let c = 0; - for (const wallet of this.wallets) { - if (c++ === index) { - txs = txs.concat(wallet.getTransactions()); - } - } - return txs; - } - - let txs = []; - for (const wallet of this.wallets.filter(w => includeWalletsWithHideTransactionsEnabled || !w.getHideTransactionsInWalletsList())) { - const walletTransactions = wallet.getTransactions(); - const walletID = wallet.getID(); - for (const t of walletTransactions) { - t.walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); - t.walletID = walletID; - } - txs = txs.concat(walletTransactions); - } - - for (const t of txs) { - t.sort_ts = +new Date(t.received); - } - - return txs - .sort(function (a, b) { - return b.sort_ts - a.sort_ts; - }) - .slice(0, limit); - }; - - /** - * Getter for a sum of all balances of all wallets - * - * @return {number} - */ - getBalance = () => { - let finalBalance = 0; - for (const wal of this.wallets) { - finalBalance += wal.getBalance(); - } - return finalBalance; - }; - - isAdvancedModeEnabled = async () => { - try { - return !!(await AsyncStorage.getItem(AppStorage.ADVANCED_MODE_ENABLED)); - } catch (_) {} - return false; - }; - - setIsAdvancedModeEnabled = async value => { - await AsyncStorage.setItem(AppStorage.ADVANCED_MODE_ENABLED, value ? '1' : ''); - }; - - isHandoffEnabled = async () => { - try { - return !!(await AsyncStorage.getItem(AppStorage.HANDOFF_STORAGE_KEY)); - } catch (_) {} - return false; - }; - - setIsHandoffEnabled = async value => { - await AsyncStorage.setItem(AppStorage.HANDOFF_STORAGE_KEY, value ? '1' : ''); - }; - - isDoNotTrackEnabled = async () => { - try { - return !!(await AsyncStorage.getItem(AppStorage.DO_NOT_TRACK)); - } catch (_) {} - return false; - }; - - setDoNotTrack = async value => { - await AsyncStorage.setItem(AppStorage.DO_NOT_TRACK, value ? '1' : ''); - }; - - /** - * Simple async sleeper function - * - * @param ms {number} Milliseconds to sleep - * @returns {Promise | Promise<*>>} - */ - sleep = ms => { - return new Promise(resolve => setTimeout(resolve, ms)); - }; - - purgeRealmKeyValueFile() { - const path = 'keyvalue.realm'; - return Realm.deleteFile({ - path, - }); - } -} - -const BlueApp = new AppStorage(); -// If attempt reaches 10, a wipe keychain option will be provided to the user. -let unlockAttempt = 0; - -const startAndDecrypt = async retry => { - console.log('startAndDecrypt'); - if (BlueApp.getWallets().length > 0) { - console.log('App already has some wallets, so we are in already started state, exiting startAndDecrypt'); - return true; - } - await BlueApp.migrateKeys(); - let password = false; - if (await BlueApp.storageIsEncrypted()) { - do { - password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, false); - } while (!password); - } - let success = false; - let wasException = false; - try { - success = await BlueApp.loadFromDisk(password); - } catch (error) { - // in case of exception reading from keystore, lets retry instead of assuming there is no storage and - // proceeding with no wallets - console.warn('exception loading from disk:', error); - wasException = true; - } - - if (wasException) { - // retrying, but only once - try { - await new Promise(resolve => setTimeout(resolve, 3000)); // sleep - success = await BlueApp.loadFromDisk(password); - } catch (error) { - console.warn('second exception loading from disk:', error); - } - } - - if (success) { - console.log('loaded from disk'); - // We want to return true to let the UnlockWith screen that its ok to proceed. - return true; - } - - if (password) { - // we had password and yet could not load/decrypt - unlockAttempt++; - if (unlockAttempt < 10 || Platform.OS !== 'ios') { - return startAndDecrypt(true); - } else { - unlockAttempt = 0; - Biometric.showKeychainWipeAlert(); - // We want to return false to let the UnlockWith screen that it is NOT ok to proceed. - return false; - } - } else { - unlockAttempt = 0; - // Return true because there was no wallet data in keychain. Proceed. - return true; - } -}; - -BlueApp.startAndDecrypt = startAndDecrypt; -BlueApp.AppStorage = AppStorage; -currency.init(); - -module.exports = BlueApp; diff --git a/BlueComponents.js b/BlueComponents.js deleted file mode 100644 index bb49e91b87c..00000000000 --- a/BlueComponents.js +++ /dev/null @@ -1,870 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import React, { Component, forwardRef } from 'react'; -import PropTypes from 'prop-types'; -import { Icon, Text, Header, ListItem, Avatar } from 'react-native-elements'; -import { - ActivityIndicator, - Alert, - Animated, - Dimensions, - Image, - InputAccessoryView, - Keyboard, - KeyboardAvoidingView, - Platform, - SafeAreaView, - StyleSheet, - Switch, - TextInput, - TouchableOpacity, - View, - I18nManager, - ImageBackground, -} from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import NetworkTransactionFees, { NetworkTransactionFee, NetworkTransactionFeeType } from './models/networkTransactionFees'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useTheme } from '@react-navigation/native'; -import { BlueCurrentTheme } from './components/themes'; -import PlusIcon from './components/icons/PlusIcon'; -import loc, { formatStringAddTwoWhiteSpaces } from './loc'; - -const { height, width } = Dimensions.get('window'); -const aspectRatio = height / width; -let isIpad; -if (aspectRatio > 1.6) { - isIpad = false; -} else { - isIpad = true; -} - -export const BlueButton = props => { - const { colors } = useTheme(); - - let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.mainColor || BlueCurrentTheme.colors.mainColor; - let fontColor = props.buttonTextColor || colors.buttonTextColor; - if (props.disabled === true) { - backgroundColor = colors.buttonDisabledBackgroundColor; - fontColor = colors.buttonDisabledTextColor; - } - - return ( - - - {props.icon && } - {props.title && {props.title}} - - - ); -}; - -export const SecondButton = forwardRef((props, ref) => { - const { colors } = useTheme(); - let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonBlueBackgroundColor; - let fontColor = colors.buttonTextColor; - if (props.disabled === true) { - backgroundColor = colors.buttonDisabledBackgroundColor; - fontColor = colors.buttonDisabledTextColor; - } - - return ( - - - {props.icon && } - {props.title && {props.title}} - - - ); -}); - -export const BitcoinButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.wallets.add_bitcoin} - - - {loc.wallets.add_bitcoin_explain} - - - - - - ); -}; - -export const VaultButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.multisig.multisig_vault} - - - {loc.multisig.multisig_vault_explain} - - - - - - ); -}; - -export const LightningButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - - {loc.wallets.add_lightning} - - - {loc.wallets.add_lightning_explain} - - - - - - ); -}; - -/** - * TODO: remove this comment once this file gets properly converted to typescript. - * - * @type {React.FC} - */ -export const BlueButtonLink = forwardRef((props, ref) => { - const { colors } = useTheme(); - return ( - - {props.title} - - ); -}); - -export const BlueAlertWalletExportReminder = ({ onSuccess = () => {}, onFailure }) => { - Alert.alert( - loc.wallets.details_title, - loc.pleasebackup.ask, - [ - { text: loc.pleasebackup.ask_yes, onPress: onSuccess, style: 'cancel' }, - { text: loc.pleasebackup.ask_no, onPress: onFailure }, - ], - { cancelable: false }, - ); -}; - -export const BluePrivateBalance = () => { - return ( - - - - - ); -}; - -export const BlueCopyToClipboardButton = ({ stringToCopy, displayText = false }) => { - return ( - Clipboard.setString(stringToCopy)}> - {displayText || loc.transactions.details_copy} - - ); -}; - -export class BlueCopyTextToClipboard extends Component { - static propTypes = { - text: PropTypes.string, - truncated: PropTypes.bool, - }; - - static defaultProps = { - text: '', - truncated: false, - }; - - constructor(props) { - super(props); - this.state = { hasTappedText: false, address: props.text }; - } - - static getDerivedStateFromProps(props, state) { - if (state.hasTappedText) { - return { hasTappedText: state.hasTappedText, address: state.address, truncated: props.truncated }; - } else { - return { hasTappedText: state.hasTappedText, address: props.text, truncated: props.truncated }; - } - } - - copyToClipboard = () => { - this.setState({ hasTappedText: true }, () => { - Clipboard.setString(this.props.text); - this.setState({ address: loc.wallets.xpub_copiedToClipboard }, () => { - setTimeout(() => { - this.setState({ hasTappedText: false, address: this.props.text }); - }, 1000); - }); - }); - }; - - render() { - return ( - - - - {this.state.address} - - - - ); - } -} - -const styleCopyTextToClipboard = StyleSheet.create({ - address: { - marginVertical: 32, - fontSize: 15, - color: '#9aa0aa', - textAlign: 'center', - }, -}); - -export const SafeBlueArea = props => { - const { style, ...nonStyleProps } = props; - const { colors } = useTheme(); - const baseStyle = { flex: 1, backgroundColor: colors.background }; - return ; -}; - -export const BlueCard = props => { - return ; -}; - -export const BlueText = props => { - const { colors } = useTheme(); - const style = StyleSheet.compose({ color: colors.foregroundColor, writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr' }, props.style); - return ; -}; - -export const BlueTextCentered = props => { - const { colors } = useTheme(); - return ; -}; - -export const BlueListItem = React.memo(props => { - const { colors } = useTheme(); - - return ( - - {props.leftAvatar && {props.leftAvatar}} - {props.leftIcon && } - - - {props.title} - - {props.subtitle && ( - - {props.subtitle} - - )} - - {props.rightTitle && ( - - - {props.rightTitle} - - - )} - {props.isLoading ? ( - - ) : ( - <> - {props.chevron && } - {props.rightIcon && } - {props.switch && } - {props.checkmark && } - - )} - - ); -}); - -export const BlueFormLabel = props => { - const { colors } = useTheme(); - - return ( - - ); -}; - -export const BlueFormMultiInput = props => { - const { colors } = useTheme(); - - return ( - - ); -}; - -export const BlueHeaderDefaultSub = props => { - const { colors } = useTheme(); - - return ( - -
- {props.leftText} - - } - {...props} - /> - - ); -}; - -export const BlueHeaderDefaultMain = props => { - const { colors } = useTheme(); - const { isDrawerList } = props; - return ( - - - {props.leftText} - - - - ); -}; - -export const BlueSpacing = props => { - return ; -}; - -export const BlueSpacing40 = props => { - return ; -}; - -export class is { - static ipad() { - return isIpad; - } -} - -export const BlueSpacing20 = props => { - const { horizontal = false } = props; - return ; -}; - -export const BlueSpacing10 = props => { - return ; -}; - -export const BlueDismissKeyboardInputAccessory = () => { - const { colors } = useTheme(); - BlueDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDismissKeyboardInputAccessory'; - - return Platform.OS !== 'ios' ? null : ( - - - - - - ); -}; - -export const BlueDoneAndDismissKeyboardInputAccessory = props => { - const { colors } = useTheme(); - BlueDoneAndDismissKeyboardInputAccessory.InputAccessoryViewID = 'BlueDoneAndDismissKeyboardInputAccessory'; - - const onPasteTapped = async () => { - const clipboard = await Clipboard.getString(); - props.onPasteTapped(clipboard); - }; - - const inputView = ( - - - - - - ); - - if (Platform.OS === 'ios') { - return {inputView}; - } else { - return {inputView}; - } -}; - -export const BlueLoading = props => { - return ( - - - - ); -}; - -export class BlueReplaceFeeSuggestions extends Component { - static propTypes = { - onFeeSelected: PropTypes.func.isRequired, - transactionMinimum: PropTypes.number.isRequired, - }; - - static defaultProps = { - transactionMinimum: 1, - }; - - state = { - customFeeValue: '1', - }; - - async componentDidMount() { - try { - const cachedNetworkTransactionFees = JSON.parse(await AsyncStorage.getItem(NetworkTransactionFee.StorageKey)); - - if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) { - this.setState({ networkFees: cachedNetworkTransactionFees }, () => this.onFeeSelected(NetworkTransactionFeeType.FAST)); - } - } catch (_) {} - const networkFees = await NetworkTransactionFees.recommendedFees(); - this.setState({ networkFees }, () => this.onFeeSelected(NetworkTransactionFeeType.FAST)); - } - - onFeeSelected = selectedFeeType => { - if (selectedFeeType !== NetworkTransactionFeeType.CUSTOM) { - Keyboard.dismiss(); - } - if (selectedFeeType === NetworkTransactionFeeType.FAST) { - this.props.onFeeSelected(this.state.networkFees.fastestFee); - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.fastestFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.MEDIUM) { - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.mediumFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.SLOW) { - this.setState({ selectedFeeType }, () => this.props.onFeeSelected(this.state.networkFees.slowFee)); - } else if (selectedFeeType === NetworkTransactionFeeType.CUSTOM) { - this.props.onFeeSelected(Number(this.state.customFeeValue)); - } - }; - - onCustomFeeTextChange = customFee => { - const customFeeValue = customFee.replace(/[^0-9]/g, ''); - this.setState({ customFeeValue, selectedFeeType: NetworkTransactionFeeType.CUSTOM }, () => { - this.onFeeSelected(NetworkTransactionFeeType.CUSTOM); - }); - }; - - render() { - const { networkFees, selectedFeeType } = this.state; - - return ( - - {networkFees && - [ - { - label: loc.send.fee_fast, - time: loc.send.fee_10m, - type: NetworkTransactionFeeType.FAST, - rate: networkFees.fastestFee, - active: selectedFeeType === NetworkTransactionFeeType.FAST, - }, - { - label: formatStringAddTwoWhiteSpaces(loc.send.fee_medium), - time: loc.send.fee_3h, - type: NetworkTransactionFeeType.MEDIUM, - rate: networkFees.mediumFee, - active: selectedFeeType === NetworkTransactionFeeType.MEDIUM, - }, - { - label: loc.send.fee_slow, - time: loc.send.fee_1d, - type: NetworkTransactionFeeType.SLOW, - rate: networkFees.slowFee, - active: selectedFeeType === NetworkTransactionFeeType.SLOW, - }, - ].map(({ label, type, time, rate, active }, index) => ( - this.onFeeSelected(type)} - style={[ - { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 }, - active && { borderRadius: 8, backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor }, - ]} - > - - {label} - - ~{time} - - - - {rate} sat/byte - - - ))} - this.customTextInput.focus()} - style={[ - { paddingHorizontal: 16, paddingVertical: 8, marginBottom: 10 }, - selectedFeeType === NetworkTransactionFeeType.CUSTOM && { - borderRadius: 8, - backgroundColor: BlueCurrentTheme.colors.incomingBackgroundColor, - }, - ]} - > - - - {formatStringAddTwoWhiteSpaces(loc.send.fee_custom)} - - - - (this.customTextInput = ref)} - maxLength={9} - style={{ - backgroundColor: BlueCurrentTheme.colors.inputBackgroundColor, - borderBottomColor: BlueCurrentTheme.colors.formBorder, - borderBottomWidth: 0.5, - borderColor: BlueCurrentTheme.colors.formBorder, - borderRadius: 4, - borderWidth: 1.0, - color: '#81868e', - flex: 1, - marginRight: 10, - minHeight: 33, - paddingRight: 5, - paddingLeft: 5, - }} - onFocus={() => this.onCustomFeeTextChange(this.state.customFeeValue)} - defaultValue={this.props.transactionMinimum} - placeholder={loc.send.fee_satvbyte} - placeholderTextColor="#81868e" - inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} - /> - sat/byte - - - - {loc.formatString(loc.send.fee_replace_minvb, { min: this.props.transactionMinimum })} - - - ); - } -} - -export function BlueBigCheckmark({ style }) { - const defaultStyles = { - backgroundColor: '#ccddf9', - width: 120, - height: 120, - borderRadius: 60, - alignSelf: 'center', - justifyContent: 'center', - marginTop: 0, - marginBottom: 0, - }; - const mergedStyles = { ...defaultStyles, ...style }; - return ( - - - - ); -} - -const tabsStyles = StyleSheet.create({ - root: { - flexDirection: 'row', - height: 50, - borderColor: '#e3e3e3', - borderBottomWidth: 1, - }, - tabRoot: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - borderColor: 'white', - borderBottomWidth: 2, - }, -}); - -export const BlueTabs = ({ active, onSwitch, tabs }) => ( - - {tabs.map((Tab, i) => ( - onSwitch(i)} - style={[ - tabsStyles.tabRoot, - active === i && { - borderColor: BlueCurrentTheme.colors.buttonAlternativeTextColor, - borderBottomWidth: 2, - }, - ]} - > - - - ))} - -); diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..124c3105103 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,76 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +BlueWallet is a Bitcoin & Lightning Network wallet built with React Native and Electrum. Cross-platform mobile app (iOS/Android/macOS via Catalyst). + +## Common Commands + +```bash +# Development +npm start # Start Metro bundler +npm run ios # Run on iOS +npm run android # Run on Android + +# Testing +npm test # Full suite (lint + unit + integration) +npm run lint # ESLint + TypeScript check + unused loc keys +npm run lint:fix # Auto-fix linting issues +npm run unit # Jest unit tests only + +# E2E Testing (Detox) +npm run e2e:debug # Debug build and test on Android +npm run e2e:release-test # Release build test + +# Clean builds +npm run clean # Full clean (gradle, cache, node_modules) +npm run clean:ios # iOS clean (Pods + node_modules) +npm run android:clean # Android clean +``` + +## Architecture + +**Directory Structure:** +- `components/` - React components and Context providers (SettingsProvider, StorageProvider) +- `class/` - Core business logic including wallet implementations in `class/wallets/` +- `blue_modules/` - Utility modules (BlueElectrum, currency, encryption, etc.) +- `screen/` - Navigation screens organized by feature (wallets, send, receive, settings, lnd) +- `navigation/` - React Navigation setup with typed param lists +- `hooks/` - Custom React hooks (useStorage, useSettings, useBiometrics, etc.) +- `loc/` - Localization files (en.json as source, 55+ languages) +- `models/` - Type definitions for units, fiat, block explorers +- `tests/unit/`, `tests/integration/`, `tests/e2e/` - Test suites + +**Wallet System:** +Multiple wallet implementations in `class/wallets/`: Legacy, SegWit (P2SH, Bech32), Taproot, HD variants, Lightning (Custodian, Ark), Multisig, Watch-only. Types defined in `class/wallets/types.ts`. + +**State Management:** +React Context providers wrap the app. Custom hooks expose state logic. Realm for database, AsyncStorage for persistence, Keychain for secrets. + +**Navigation:** +React Navigation 7.x with native stack. Typed params in `navigation/DetailViewStackParamList.ts` and other param list files. + +## Code Conventions + +**Commit Prefixes:** REL, FIX, ADD, REF, TST, OPS, DOC (e.g., `"ADD: new feature"`) + +**TypeScript:** All new files must be TypeScript. Strict mode enabled. + +**Dependencies:** Do not add new dependencies without strong justification. Bonus for removing dependencies. + +**Patches:** Local fixes to `node_modules` live in `patches/` and are applied by `patch-package` on `postinstall`. Each patch is documented in `patches/README.md` (what/why + upstream issue link); update it when adding or removing a patch. + +**Components:** New components go in `components/`, not legacy `BlueComponents.js`. + +**Linting Rules:** +- No inline styles in React Native (`react-native/no-inline-styles`: error) +- No unused styles (`react-native/no-unused-styles`: error) +- Prettier: single quotes, 140 char width, trailing commas + +**Localization:** Keys in `loc/en.json`. Run `find-unused-loc.js` to detect unused keys. See `loc/vocabulary.md` for the canonical glossary of Bitcoin/Lightning terms and their per-language renderings — use it as ground truth when translating or generating translations with LLMs. + +## Testing + +Unit tests in `tests/unit/` use Jest with `assert`. Test setup mocks React Native modules (Clipboard, Push Notifications, Keychain, etc.). Integration tests require environment variables for test mnemonics (HD_MNEMONIC, HD_MNEMONIC_BIP84, etc.). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 734eb4d9c2f..69e3432206e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,13 @@ -All commits should have one of the following prefixes: REL, FIX, ADD, TST, OPS, DOC. For example `"ADD: new feature"`. -Adding new feature is ADD, fixing a bug is FIX, something related to infrastructure is OPS etc. +## Commits + +All commits should have one of the following prefixes: REL, FIX, ADD, REF, TST, OPS, DOC. For example `"ADD: new feature"`. +Adding new feature is ADD, fixing a bug is FIX, something related to infrastructure is OPS etc. REL is for releases, REF is for +refactoring, DOC is for changing documentation (like this file). Commits should be atomic: one commit - one feature, one commit - one bugfix etc. +## Releases + When you tag a new release, use the following example: `git tag -m "REL v1.4.0: 157c9c2" v1.4.0 -s` You may get the commit hash from git log. Don't forget to push tags `git push origin --tags` @@ -13,5 +18,32 @@ When tagging a new release, make sure to increment version in package.json and o In the commit where you up version you can have the commit message as `"REL vX.X.X: Summary message"`. +## Guidelines Do *not* add new dependencies. Bonus points if you manage to actually remove a dependency. + +Bumped production dependencies must be pinned. + +All new files must be in typescript. Bonus points if you convert some of the existing files to typescript. + +New components must go in `components/`. Bonus points if you refactor some of old components in `BlueComponents.js` to separate files. + +Add tests if it makes sense. Bonus points for e2e tests. + +Added / modified texts should be only in `loc/en.json` - there should be no inline texts in UI code. Dont touch other localization json files - those are modified outside by Transifex. + +Make sure the code is not overengineered and not bloated. + +Aim for the absolute minimal change that does the job while still being readable. + +If you added / altered tests, make sure they are not bullshit: dont just test mocks, or that data youve put into mocks is there; tests should check happy paths as well as edge cases and NOT be bloated / overengineered. + +Dont touch lines that are not relevant to the change - they show up in the diff and distract, plus hijack line ownership in `git blame`. + +# PRs + +Before creating a PR, make sure unit/integration/lint tests pass. You might not have all env variables but thats ok - some tests will be skipped. + +PRs must have short description of why (it was implemented) and how (it works under the hood). + +When submitting PR with a UI (or visual) change it must include screenshot (from the emulator or the device) how the proposed change looks, even better - a video. diff --git a/Gemfile b/Gemfile index 67a72298669..6dec2a672ef 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,22 @@ -source 'https://rubygems.org' +source "https://rubygems.org" # You may use http://rbenv.org/ or https://rvm.io/ to install and use this version -ruby '>= 2.6.10' +ruby "3.4.10" +gem "fastlane", "~> 2.237.0" +# Exclude problematic versions of cocoapods and activesupport that causes build failures. +gem 'cocoapods', '>= 1.13', '!= 1.15.0', '!= 1.15.1' +gem 'activesupport', '>= 6.1.7.5', '!= 7.1.0' +gem 'xcodeproj', '< 1.28.2' +gem 'concurrent-ruby', '< 1.3.8' -gem 'cocoapods', '~> 1.11', '>= 1.11.3' \ No newline at end of file +# Ruby 3.4.0 removed these from the standard library +gem 'bigdecimal' +gem 'logger' +gem 'benchmark' +gem 'mutex_m' + +# Required for App Store Connect API +gem "jwt" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock index 285424cd4b6..d5998eb025e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,30 +1,60 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.5) - rexml - activesupport (6.1.4.4) - concurrent-ruby (~> 1.0, >= 1.0.2) + CFPropertyList (3.0.8) + abbrev (0.1.2) + activesupport (7.2.3.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) - minitest (>= 5.1) - tzinfo (~> 2.0) - zeitwerk (~> 2.3) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) algoliasearch (1.27.5) httpclient (~> 2.8, >= 2.8.3) json (>= 1.5.1) + artifactory (3.0.17) atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1268.0) + aws-sdk-core (3.254.0) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.130.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.227.0) + aws-sdk-core (~> 3, >= 3.254.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + benchmark (0.5.0) + bigdecimal (4.1.2) claide (1.1.0) - cocoapods (1.11.2) + cocoapods (1.15.2) addressable (~> 2.8) claide (>= 1.0.2, < 2.0) - cocoapods-core (= 1.11.2) + cocoapods-core (= 1.15.2) cocoapods-deintegrate (>= 1.0.3, < 2.0) - cocoapods-downloader (>= 1.4.0, < 2.0) + cocoapods-downloader (>= 2.1, < 3.0) cocoapods-plugins (>= 1.0.0, < 2.0) cocoapods-search (>= 1.0.0, < 2.0) - cocoapods-trunk (>= 1.4.0, < 2.0) + cocoapods-trunk (>= 1.6.0, < 2.0) cocoapods-try (>= 1.1.0, < 2.0) colored2 (~> 3.1) escape (~> 0.0.4) @@ -32,10 +62,10 @@ GEM gh_inspector (~> 1.0) molinillo (~> 0.8.0) nap (~> 1.0) - ruby-macho (>= 1.0, < 3.0) - xcodeproj (>= 1.21.0, < 2.0) - cocoapods-core (1.11.2) - activesupport (>= 5.0, < 7) + ruby-macho (>= 2.3.0, < 3.0) + xcodeproj (>= 1.23.0, < 2.0) + cocoapods-core (1.15.2) + activesupport (>= 5.0, < 8) addressable (~> 2.8) algoliasearch (~> 1.0) concurrent-ruby (~> 1.1) @@ -45,7 +75,7 @@ GEM public_suffix (~> 4.0) typhoeus (~> 1.0) cocoapods-deintegrate (1.0.5) - cocoapods-downloader (1.5.1) + cocoapods-downloader (2.1) cocoapods-plugins (1.0.0) nap cocoapods-search (1.0.1) @@ -53,48 +83,416 @@ GEM nap (>= 0.8, < 2.0) netrc (~> 0.11) cocoapods-try (1.2.0) + colored (1.2) colored2 (3.1.2) - concurrent-ruby (1.1.9) + commander (4.6.0) + highline (~> 2.0.0) + concurrent-ruby (1.3.7) + connection_pool (3.0.2) + csv (3.3.5) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + domain_name (0.6.20240107) + dotenv (2.8.1) + drb (2.2.3) + emoji_regex (3.2.3) escape (0.0.4) - ethon (0.15.0) + ethon (0.18.0) ffi (>= 1.15.0) - ffi (1.15.5) + logger + excon (0.112.0) + faraday (1.10.6) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.8) + faraday (>= 0.8.0) + http-cookie (>= 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.1) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.2.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.4) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.1) + fastlane (2.237.0) + CFPropertyList (>= 2.3, < 5.0.0) + abbrev (~> 0.1) + addressable (>= 2.9.0, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.197) + babosa (>= 1.0.3, < 2.0.0) + base64 (~> 0.2) + benchmark (>= 0.1.0) + bundler (>= 2.4.0, < 5.0.0) + colored (~> 1.2) + commander (~> 4.6) + csv (~> 3.3) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 2.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.1.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.3.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.10.3, < 4) + logger (>= 1.6, < 2.0) + mini_magick (>= 4.9.4, < 5.0.0) + multi_json (~> 1.12) + multipart-post (>= 2.0.0, < 3.0.0) + mutex_m (~> 0.3) + naturally (~> 2.2) + nkf (~> 0.2) + optparse (>= 0.1.1, < 1.0.0) + ostruct (>= 0.1.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-browserstack (0.3.4) + rest-client (~> 2.0, >= 2.0.2) + fastlane-plugin-bugsnag (3.0.0) + abbrev + git + xml-simple + fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) + fastlane-sirp (1.1.0) + ffi (1.17.3) fourflusher (2.3.1) fuzzy_match (2.0.4) gh_inspector (1.1.3) - httpclient (2.8.3) - i18n (1.9.1) + git (4.3.1) + activesupport (>= 5.0) + addressable (~> 2.8) + process_executer (~> 4.0) + rchardet (~> 1.9) + google-apis-androidpublisher_v3 (0.104.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.28.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-playcustomapp_v1 (0.18.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.64.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.9.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.2.2) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.7.0) + google-cloud-storage (1.62.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-logging-utils (0.2.0) + googleauth (1.17.1) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.2) + google-logging-utils (~> 0.1) + jwt (>= 1.4, < 4.0) + os (>= 0.9, < 2.0) + pstore (~> 0.1) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-accept (1.7.0) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + i18n (1.14.8) concurrent-ruby (~> 1.0) - json (2.6.1) - minitest (5.15.0) + jmespath (1.6.2) + json (2.21.0) + jwt (2.10.3) + base64 + logger (1.7.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2026.0317) + mini_magick (4.13.2) + mini_mime (1.1.5) + minitest (5.27.0) molinillo (0.8.0) - nanaimo (0.3.0) + multi_json (1.21.1) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) nap (1.1.0) + naturally (2.3.0) netrc (0.11.0) - public_suffix (4.0.6) - rexml (3.2.5) + nkf (0.3.0) + optparse (0.8.1) + os (1.1.4) + ostruct (0.6.3) + plist (3.7.2) + process_executer (4.0.2) + track_open_instances (~> 0.1) + pstore (0.2.1) + public_suffix (4.0.7) + rake (13.4.2) + rchardet (1.10.0) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + retriable (3.8.0) + rexml (3.4.4) + rouge (3.28.0) ruby-macho (2.5.1) - typhoeus (1.4.0) - ethon (>= 0.9.0) - tzinfo (2.0.4) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + securerandom (0.4.1) + security (0.1.5) + signet (0.22.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + simctl (1.6.10) + CFPropertyList + naturally + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + track_open_instances (0.1.15) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + typhoeus (1.6.0) + ethon (>= 0.18.0) + tzinfo (2.0.6) concurrent-ruby (~> 1.0) - xcodeproj (1.21.0) + uber (0.1.0) + unicode-display_width (2.6.0) + word_wrap (1.0.0) + xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) + base64 claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) - rexml (~> 3.2.4) - zeitwerk (2.5.4) + nanaimo (~> 0.4.0) + nkf + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + xml-simple (1.1.9) + rexml PLATFORMS ruby DEPENDENCIES - cocoapods (~> 1.11, >= 1.11.2) + activesupport (>= 6.1.7.5, != 7.1.0) + benchmark + bigdecimal + cocoapods (>= 1.13, != 1.15.1, != 1.15.0) + concurrent-ruby (< 1.3.8) + fastlane (~> 2.237.0) + fastlane-plugin-browserstack + fastlane-plugin-bugsnag + fastlane-plugin-bugsnag_sourcemaps_upload + jwt + logger + mutex_m + xcodeproj (< 1.28.2) + +CHECKSUMS + CFPropertyList (3.0.8) sha256=2c99d0d980536d3d7ab252f7bd59ac8be50fbdd1ff487c98c949bb66bb114261 + abbrev (0.1.2) sha256=ad1b4eaaaed4cb722d5684d63949e4bde1d34f2a95e20db93aecfe7cbac74242 + activesupport (7.2.3.1) sha256=11ebed516a43a0bb47346227a35ebae4d9427465a7c9eb197a03d5c8d283cb34 + addressable (2.9.0) sha256=7fdf6ac3660f7f4e867a0838be3f6cf722ace541dd97767fa42bc6cfa980c7af + algoliasearch (1.27.5) sha256=26c1cddf3c2ec4bd60c148389e42702c98fdac862881dc6b07a4c0b89ffec853 + artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 + atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f + aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b + aws-partitions (1.1268.0) sha256=61c41c23445380425455e549dcb283f0df50aa2f3d71892f692572fed9ef6c0f + aws-sdk-core (3.254.0) sha256=ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe + aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 + aws-sdk-s3 (1.227.0) sha256=552b23bf9a37c7db4957e9291543e61c8de886cf31d1d45ea3b429df0feea939 + aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 + babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c + bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e + cocoapods (1.15.2) sha256=f0f5153de8d028d133b96f423e04f37fb97a1da0d11dda581a9f46c0cba4090a + cocoapods-core (1.15.2) sha256=322650d97fe1ad4c0831a09669764b888bd91c6d79d0f6bb07281a17667a2136 + cocoapods-deintegrate (1.0.5) sha256=517c2a448ef563afe99b6e7668704c27f5de9e02715a88ee9de6974dc1b3f6a2 + cocoapods-downloader (2.1) sha256=bb6ebe1b3966dc4055de54f7a28b773485ac724fdf575d9bee2212d235e7b6d1 + cocoapods-plugins (1.0.0) sha256=725d17ce90b52f862e73476623fd91441b4430b742d8a071000831efb440ca9a + cocoapods-search (1.0.1) sha256=1b133b0e6719ed439bd840e84a1828cca46425ab73a11eff5e096c3b2df05589 + cocoapods-trunk (1.6.0) sha256=5f5bda8c172afead48fa2d43a718cf534b1313c367ba1194cebdeb9bfee9ed31 + cocoapods-try (1.2.0) sha256=145b946c6e7747ed0301d975165157951153d27469e6b2763c83e25c84b9defe + colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c + colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a + commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 + concurrent-ruby (1.3.7) sha256=4412caec3a5ea2e5fdc52076724c071a81f2c0593d83b2ac8cbb8ca63b3151b0 + connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 + digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 + domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 + dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 + drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b + escape (0.0.4) sha256=e49f44ae2b4f47c6a3abd544ae77fe4157802794e32f19b8e773cbc4dcec4169 + ethon (0.18.0) sha256=b598afc9f30448cb068b850714b7d6948e941476095d04f90a4ac65b8d6efcb2 + excon (0.112.0) sha256=daf9ac3a4c2fc9aa48383a33da77ecb44fa395111e973084d5c52f6f214ae0f0 + faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb + faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 + faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 + faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 + faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 + faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 + faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 + faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 + faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 + faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa + faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 + fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane-plugin-browserstack (0.3.4) sha256=a4f3e4a552e2390a4733570857512571535912100ffada177d5374413f2c1333 + fastlane-plugin-bugsnag (3.0.0) sha256=8ddac4b79cb4b5d00432cccd5789a9e1a1119c29f7773a27d01b1d8a2363915d + fastlane-plugin-bugsnag_sourcemaps_upload (0.2.0) sha256=a05afaefa81a7bf56c36386dddeb0931db31ead6886e3eae24f9683bda1a064d + fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 + ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c + fourflusher (2.3.1) sha256=1b3de61c7c791b6a4e64f31e3719eb25203d151746bb519a0292bff1065ccaa9 + fuzzy_match (2.0.4) sha256=b5de4f95816589c5b5c3ad13770c0af539b75131c158135b3f3bbba75d0cfca5 + gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 + git (4.3.1) sha256=91ca566c39766a033e61a148c8f470908bd4786b818f8f3ff566d3a9a0200c50 + google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff + google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 + google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 + google-apis-storage_v1 (0.64.0) sha256=75b11afa2edcee859b84c7a6972ee4456314eeef5f762827fd6cf5c5ffaf93f2 + google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 + google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 + google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 + google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330 + google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b + googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 + highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 + http-accept (1.7.0) sha256=c626860682bfbb3b46462f8c39cd470fd7b0584f61b3cc9df5b2e9eb9972a126 + http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 + httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 + json (2.21.0) sha256=8de199e53f93450414b9fd49811012a73f3e3265b60468c7274d1cacebde1969 + jwt (2.10.3) sha256=e4d9352fbc7309b1a7448c7dd713dfe4d8c47077af80759cdbed8f878ea0b484 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mime-types (3.7.0) sha256=dcebf61c246f08e15a4de34e386ebe8233791e868564a470c3fe77c00eed5e56 + mime-types-data (3.2026.0317) sha256=77f078a4d8631d52b842ba77099734b06eddb7ad339d792e746d2272b67e511b + mini_magick (4.13.2) sha256=71d6258e0e8a3d04a9a0a09784d5d857b403a198a51dd4f882510435eb95ddd9 + mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef + minitest (5.27.0) sha256=2d3b17f8a36fe7801c1adcffdbc38233b938eb0b4966e97a6739055a45fa77d5 + molinillo (0.8.0) sha256=efbff2716324e2a30bccd3eba1ff3a735f4d5d53ffddbc6a2f32c0ca9433045d + multi_json (1.21.1) sha256=e6126a31808e3b4d19f483c775ceac34df190dffa62adfb63a165ee14ba68080 + multipart-post (2.4.1) sha256=9872d03a8e552020ca096adadbf5e3cb1cd1cdd6acd3c161136b8a5737cdb4a8 + mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 + nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 + nap (1.1.0) sha256=949691660f9d041d75be611bb2a8d2fd559c467537deac241f4097d9b5eea576 + naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + netrc (0.11.0) sha256=de1ce33da8c99ab1d97871726cba75151113f117146becbe45aa85cb3dabee3f + nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 + optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a + os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f + ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 + plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + process_executer (4.0.2) sha256=c73eb646d450044241c973a8360f6326e33ec5ad933f7acf503f6f3579873a71 + pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a + public_suffix (4.0.7) sha256=8be161e2421f8d45b0098c042c06486789731ea93dc3a896d30554ee38b573b8 + rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rchardet (1.10.0) sha256=d5ea2ed61a720a220f1914778208e718a0c7ed2a484b6d357ba695aa7001390f + representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace + rest-client (2.1.0) sha256=35a6400bdb14fae28596618e312776c158f7ebbb0ccad752ff4fa142bf2747e3 + retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 + ruby-macho (2.5.1) sha256=9075e52e0f9270b552a90b24fcc6219ad149b0d15eae1bc364ecd0ac8984f5c9 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef + rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 + securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 + security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 + signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 + simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b + terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + track_open_instances (0.1.15) sha256=7f0e48821e6b4c881daaa40fb1583e308937c22a9c84883c150b399c3b5c3029 + trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 + tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 + tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 + typhoeus (1.6.0) sha256=bacc41c23e379547e29801dc235cd1699b70b955a1ba3d32b2b877aa844c331d + tzinfo (2.0.6) sha256=8daf828cc77bcf7d63b0e3bdb6caa47e2272dcfaf4fbfe46f8c3a9df087a829b + uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 + xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc + xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 + xcpretty-travis-formatter (1.0.1) sha256=aacc332f17cb7b2cba222994e2adc74223db88724fe76341483ad3098e232f93 + xml-simple (1.1.9) sha256=d21131e519c86f1a5bc2b6d2d57d46e6998e47f18ed249b25cad86433dbd695d RUBY VERSION - ruby 2.7.4p191 + ruby 3.4.10 BUNDLED WITH - 2.2.27 + 4.0.7 diff --git a/LICENSE b/LICENSE index 9d6b974ffcd..ca4e589c8db 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 BlueWallet developers +Copyright (c) 2026 BlueWallet developers Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Navigation.js b/Navigation.js deleted file mode 100644 index 9bede94f447..00000000000 --- a/Navigation.js +++ /dev/null @@ -1,528 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { createNativeStackNavigator } from 'react-native-screens/native-stack'; -import { createDrawerNavigator } from '@react-navigation/drawer'; -import { Platform, useWindowDimensions, Dimensions, I18nManager } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -import Settings from './screen/settings/settings'; -import About from './screen/settings/about'; -import ReleaseNotes from './screen/settings/releasenotes'; -import Licensing from './screen/settings/licensing'; -import Selftest from './screen/selftest'; -import Language from './screen/settings/language'; -import Currency from './screen/settings/currency'; -import EncryptStorage from './screen/settings/encryptStorage'; -import PlausibleDeniability from './screen/plausibledeniability'; -import LightningSettings from './screen/settings/lightningSettings'; -import ElectrumSettings from './screen/settings/electrumSettings'; -import TorSettings from './screen/settings/torSettings'; -import Tools from './screen/settings/tools'; -import GeneralSettings from './screen/settings/GeneralSettings'; -import NetworkSettings from './screen/settings/NetworkSettings'; -import NotificationSettings from './screen/settings/notificationSettings'; -import DefaultView from './screen/settings/defaultView'; - -import WalletsList from './screen/wallets/list'; -import WalletTransactions from './screen/wallets/transactions'; -import AddWallet from './screen/wallets/add'; -import WalletsAddMultisig from './screen/wallets/addMultisig'; -import WalletsAddMultisigStep2 from './screen/wallets/addMultisigStep2'; -import WalletsAddMultisigHelp from './screen/wallets/addMultisigHelp'; -import PleaseBackup from './screen/wallets/pleaseBackup'; -import PleaseBackupLNDHub from './screen/wallets/pleaseBackupLNDHub'; -import PleaseBackupLdk from './screen/wallets/pleaseBackupLdk'; -import ImportWallet from './screen/wallets/import'; -import ImportWalletDiscovery from './screen/wallets/importDiscovery'; -import ImportCustomDerivationPath from './screen/wallets/importCustomDerivationPath'; -import ImportSpeed from './screen/wallets/importSpeed'; -import WalletDetails from './screen/wallets/details'; -import WalletExport from './screen/wallets/export'; -import ExportMultisigCoordinationSetup from './screen/wallets/exportMultisigCoordinationSetup'; -import ViewEditMultisigCosigners from './screen/wallets/viewEditMultisigCosigners'; -import WalletXpub from './screen/wallets/xpub'; -import SignVerify from './screen/wallets/signVerify'; -import WalletAddresses from './screen/wallets/addresses'; -import ReorderWallets from './screen/wallets/reorderWallets'; -import SelectWallet from './screen/wallets/selectWallet'; -import ProvideEntropy from './screen/wallets/provideEntropy'; - -import TransactionDetails from './screen/transactions/details'; -import TransactionStatus from './screen/transactions/transactionStatus'; -import CPFP from './screen/transactions/CPFP'; -import RBFBumpFee from './screen/transactions/RBFBumpFee'; -import RBFCancel from './screen/transactions/RBFCancel'; - -import ReceiveDetails from './screen/receive/details'; -import AztecoRedeem from './screen/receive/aztecoRedeem'; - -import SendDetails from './screen/send/details'; -import ScanQRCode from './screen/send/ScanQRCode'; -import SendCreate from './screen/send/create'; -import Confirm from './screen/send/confirm'; -import PsbtWithHardwareWallet from './screen/send/psbtWithHardwareWallet'; -import PsbtMultisig from './screen/send/psbtMultisig'; -import PsbtMultisigQRCode from './screen/send/psbtMultisigQRCode'; -import Success from './screen/send/success'; -import Broadcast from './screen/send/broadcast'; -import IsItMyAddress from './screen/send/isItMyAddress'; -import CoinControl from './screen/send/coinControl'; - -import ScanLndInvoice from './screen/lnd/scanLndInvoice'; -import LappBrowser from './screen/lnd/browser'; -import LNDCreateInvoice from './screen/lnd/lndCreateInvoice'; -import LNDViewInvoice from './screen/lnd/lndViewInvoice'; -import LdkOpenChannel from './screen/lnd/ldkOpenChannel'; -import LdkInfo from './screen/lnd/ldkInfo'; -import LNDViewAdditionalInvoiceInformation from './screen/lnd/lndViewAdditionalInvoiceInformation'; -import LnurlPay from './screen/lnd/lnurlPay'; -import LnurlPaySuccess from './screen/lnd/lnurlPaySuccess'; -import LnurlAuth from './screen/lnd/lnurlAuth'; -import UnlockWith from './UnlockWith'; -import DrawerList from './screen/wallets/drawerList'; -import { isDesktop, isTablet, isHandset } from './blue_modules/environment'; -import SettingsPrivacy from './screen/settings/SettingsPrivacy'; -import LNDViewAdditionalInvoicePreImage from './screen/lnd/lndViewAdditionalInvoicePreImage'; -import LdkViewLogs from './screen/wallets/ldkViewLogs'; -import PaymentCode from './screen/wallets/paymentCode'; -import PaymentCodesList from './screen/wallets/paymentCodesList'; -import loc from './loc'; - -const WalletsStack = createNativeStackNavigator(); - -const WalletsRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -}; - -const AddWalletStack = createNativeStackNavigator(); -const AddWalletRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - - - - ); -}; - -// CreateTransactionStackNavigator === SendDetailsStack -const SendDetailsStack = createNativeStackNavigator(); -const SendDetailsRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - - - - - ); -}; - -const LNDCreateInvoiceStack = createNativeStackNavigator(); -const LNDCreateInvoiceRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - ); -}; - -// LightningScanInvoiceStackNavigator === ScanLndInvoiceStack -const ScanLndInvoiceStack = createNativeStackNavigator(); -const ScanLndInvoiceRoot = () => { - const theme = useTheme(); - - return ( - - - - - - - - ); -}; - -const LDKOpenChannelStack = createNativeStackNavigator(); -const LDKOpenChannelRoot = () => { - const theme = useTheme(); - - return ( - - - - - - ); -}; - -const AztecoRedeemStack = createNativeStackNavigator(); -const AztecoRedeemRoot = () => { - const theme = useTheme(); - - return ( - - - - - ); -}; - -const ScanQRCodeStack = createNativeStackNavigator(); -const ScanQRCodeRoot = () => ( - - - -); - -const UnlockWithScreenStack = createNativeStackNavigator(); -const UnlockWithScreenRoot = () => ( - - - -); - -const ReorderWalletsStack = createNativeStackNavigator(); -const ReorderWalletsStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const Drawer = createDrawerNavigator(); -const DrawerRoot = () => { - const dimensions = useWindowDimensions(); - const isLargeScreen = useMemo(() => { - return Platform.OS === 'android' ? isTablet() : (dimensions.width >= Dimensions.get('screen').width / 2 && isTablet()) || isDesktop; - }, [dimensions.width]); - const drawerStyle = useMemo(() => ({ width: isLargeScreen ? 320 : '0%' }), [isLargeScreen]); - const drawerContent = useCallback(props => (isLargeScreen ? : null), [isLargeScreen]); - - return ( - - - - ); -}; - -const ReceiveDetailsStack = createNativeStackNavigator(); -const ReceiveDetailsStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const WalletXpubStack = createNativeStackNavigator(); -const WalletXpubStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const SignVerifyStack = createNativeStackNavigator(); -const SignVerifyStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const WalletExportStack = createNativeStackNavigator(); -const WalletExportStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const LappBrowserStack = createNativeStackNavigator(); -const LappBrowserStackRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const InitStack = createNativeStackNavigator(); -const InitRoot = () => ( - - - - - -); - -const ViewEditMultisigCosignersStack = createNativeStackNavigator(); -const ViewEditMultisigCosignersRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const ExportMultisigCoordinationSetupStack = createNativeStackNavigator(); -const ExportMultisigCoordinationSetupRoot = () => { - const theme = useTheme(); - - return ( - - - - ); -}; - -const PaymentCodeStack = createNativeStackNavigator(); -const PaymentCodeStackRoot = () => { - return ( - - - - - ); -}; - -const RootStack = createNativeStackNavigator(); -const NavigationDefaultOptions = { headerShown: false, stackPresentation: isDesktop ? 'containedModal' : 'modal' }; -const Navigation = () => { - return ( - - {/* stacks */} - - - - - - - {/* screens */} - - - - - - - - - - - - - - - ); -}; - -export default InitRoot; diff --git a/NavigationService.js b/NavigationService.js deleted file mode 100644 index 05316459584..00000000000 --- a/NavigationService.js +++ /dev/null @@ -1,11 +0,0 @@ -import * as React from 'react'; - -export const navigationRef = React.createRef(); - -export function navigate(name, params) { - navigationRef.current?.navigate(name, params); -} - -export function dispatch(params) { - navigationRef.current?.dispatch(params); -} diff --git a/NavigationService.ts b/NavigationService.ts new file mode 100644 index 00000000000..e141158c4d8 --- /dev/null +++ b/NavigationService.ts @@ -0,0 +1,61 @@ +import { CommonActions, createNavigationContainerRef, NavigationAction, ParamListBase, StackActions } from '@react-navigation/native'; + +export const navigationRef = createNavigationContainerRef(); + +export function navigate(name: string, params?: ParamListBase, options?: { merge: boolean }) { + if (navigationRef.isReady()) { + navigationRef.current?.navigate({ name, params, merge: options?.merge }); + } +} + +export function dispatch(action: NavigationAction) { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(action); + } +} + +export function reset() { + if (navigationRef.isReady()) { + navigationRef.current?.reset({ + index: 0, + routes: [{ name: 'UnlockWithScreen' }], + }); + } +} + +export function popToTop() { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(StackActions.popToTop()); + } +} + +export function pop() { + if (navigationRef.isReady()) { + navigationRef.current?.dispatch(StackActions.pop()); + } +} + +export function navigateToWalletsList() { + if (navigationRef.isReady()) { + navigationRef.dispatch( + CommonActions.reset({ + index: 0, + routes: [ + { + name: 'DrawerRoot', + state: { + routes: [ + { + name: 'DetailViewStackScreensStack', + state: { + routes: [{ name: 'WalletsList' }], + }, + }, + ], + }, + }, + ], + }), + ); + } +} diff --git a/README.md b/README.md index 7b33353993f..fb14d088c1f 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # BlueWallet - A Bitcoin & Lightning Wallet [![GitHub tag](https://img.shields.io/badge/dynamic/json.svg?url=https://raw.githubusercontent.com/BlueWallet/BlueWallet/master/package.json&query=$.version&label=Version)](https://github.com/BlueWallet/BlueWallet) -[![CircleCI](https://circleci.com/gh/BlueWallet/BlueWallet.svg?style=svg)](https://circleci.com/gh/BlueWallet/BlueWallet) [![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) ![](https://img.shields.io/github/license/BlueWallet/BlueWallet.svg) @@ -76,14 +75,17 @@ In another terminal window within the BlueWallet folder: ``` npx react-native run-ios ``` +**To debug BlueWallet on the iOS Simulator, you must choose a Rosetta-compatible iOS Simulator. This can be done by navigating to the Product menu in Xcode, selecting Destination Architectures, and then opting for "Show Both." This action will reveal the simulators that support Rosetta. +** * To run on macOS using Mac Catalyst: ``` -npm run maccatalystpatches +npx pod-install +npm start ``` -Once the patches are applied, open Xcode and select "My Mac" as destination. +Open ios/BlueWallet.xcworkspace. Once the project loads, select the scheme/target BlueWallet. Click Run. ## TESTS @@ -98,11 +100,11 @@ MIT ## WANT TO CONTRIBUTE? -Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/projects/1), try to start or submit a PR, any doubts we will try to guide you. Contributors have a private telegram group, request access by email bluewallet@bluewallet.io +Grab an issue from [the backlog](https://github.com/BlueWallet/BlueWallet/issues), try to start or submit a PR, any doubts we will try to guide you. Contributors have a private telegram group, request access by email bluewallet@bluewallet.io ## Translations -We accept translations via [Transifex](https://www.transifex.com/bluewallet/bluewallet/) +We accept translations via [Transifex](https://explore.transifex.com/bluewallet/bluewallet/) To participate you need to: 1. Sign up to Transifex @@ -114,6 +116,10 @@ Please note the values in curly braces should not be translated. These are the n Transifex automatically creates Pull Request when language reaches 100% translation. We also trigger this by hand before each release, so don't worry if you can't translate everything, every word counts. +### Vocabulary glossaries + +[`loc/vocabulary.md`](loc/vocabulary.md) + the per-language files under [`loc/vocabulary/`](loc/vocabulary/) are the canonical glossary of Bitcoin/Lightning terms (Wallet, Vault, Seed, Mnemonic, Passphrase, Multisig, Payment Code, Coin Control, …) and their chosen rendering in each locale, with the reasoning behind each choice and ⚠️ anti-meaning callouts (e.g. Passcode ≠ Password, Change-output ≠ verb "to change"). Use them as ground truth when translating by hand or when feeding `loc/.json` to an LLM — terminology consistency across screens is the difference between "looks translated" and "is correct for a Bitcoin wallet". When you change a shipped string, update the matching row in the same PR. + ## Q&A Builds automated and tested with BrowserStack diff --git a/RELEASE.md b/RELEASE.md index df06d6c8ee2..c5299fff120 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,27 +2,8 @@ ## Apple -* test the build on a real device. It is imperative that you run selftest and it gives you OK -* if necessary, up version number in all relevant files (you can use `./edit-version-number.sh`) -* run `./scripts/release-notes.sh` - it prints changelog between latest tag and now, put this output under -new version in file `ios/fastlane/metadata/en-US/release_notes.txt` (on top); if file got too big -delete the oldest version from the bottom of the file -* now is a good time to commit a ver bump and release notes changes -* create this release version in App Store Connect (iTunes) and attach appropriate build. note -last 4 digits of the build and announce it - this is now a RC. no need to fill release notes yet -* `cd ios/` and then run `DELIVER_USERNAME="my_itunes_email@example.com" DELIVER_PASSWORD="my_itunes_password" fastlane deliver --force --skip_binary_upload --skip_screenshots --ignore_language_directory_validation -a io.bluewallet.bluewallet --app_version "6.6.6"` -but replace `6.6.6` with your version number - this will upload release notes to all locales in itunes -* go back to App Store Connect and press `Submit for Review`. choose Yes, we use identifiers - for installs tracking -* once its approved and released it is safe to cut a release tag: run `git tag -m "REL v6.6.6: 76ed479" v6.6.6 -s` -where `76ed479` is a latest commit in this version. replace the version as well. then run `git push origin --tags`; alternative way to tag: `git tag -a v6.0.0 2e1a00609d5a0dbc91bcda2421df0f61bdfc6b10 -m "v6.0.0" -s` -* you are awesome! +* TBD ## Android -* do android after ios usually -* test the build on a real device. We have accounts with browserstack where you can do so. -* its imperative that you run selftest and it gives you OK. note which build you are testing -* go to appcenter.ms, find this exact build under `master` builds, and press `Distribute` -> `Store` -> `Production`. -in `Release notes` write the release, this field is to smaller than iOS, so you need to keep it bellow 500 characters. -* now just wait till appcenter displays a message that it is succesfully distributed -* noice! +* TBD diff --git a/UnlockWith.js b/UnlockWith.js deleted file mode 100644 index 6bb0aa979e0..00000000000 --- a/UnlockWith.js +++ /dev/null @@ -1,141 +0,0 @@ -import React, { useContext, useEffect, useState } from 'react'; -import { View, Image, TouchableOpacity, StyleSheet, StatusBar, ActivityIndicator, useColorScheme, LayoutAnimation } from 'react-native'; -import { Icon } from 'react-native-elements'; -import Biometric from './class/biometrics'; -import LottieView from 'lottie-react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { StackActions, useNavigation, useRoute } from '@react-navigation/native'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import { isHandset } from './blue_modules/environment'; - -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - container: { - flex: 1, - justifyContent: 'space-between', - alignItems: 'center', - }, - biometric: { - flex: 1, - justifyContent: 'flex-end', - marginBottom: 58, - }, - biometricRow: { - justifyContent: 'center', - flexDirection: 'row', - }, - icon: { - width: 64, - height: 64, - }, -}); - -const UnlockWith = () => { - const { setWalletsInitialized, isStorageEncrypted, startAndDecrypt } = useContext(BlueStorageContext); - const { dispatch } = useNavigation(); - const { unlockOnComponentMount } = useRoute().params; - const [biometricType, setBiometricType] = useState(false); - const [isStorageEncryptedEnabled, setIsStorageEncryptedEnabled] = useState(false); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [animationDidFinish, setAnimationDidFinish] = useState(false); - const colorScheme = useColorScheme(); - - const initialRender = async () => { - let bt = false; - if (await Biometric.isBiometricUseCapableAndEnabled()) { - bt = await Biometric.biometricType(); - } - - setBiometricType(bt); - }; - - useEffect(() => { - initialRender(); - }, []); - - const successfullyAuthenticated = () => { - setWalletsInitialized(true); - dispatch(StackActions.replace(isHandset ? 'Navigation' : 'DrawerRoot')); - }; - - const unlockWithBiometrics = async () => { - if (await isStorageEncrypted()) { - unlockWithKey(); - } - setIsAuthenticating(true); - - if (await Biometric.unlockWithBiometrics()) { - setIsAuthenticating(false); - await startAndDecrypt(); - return successfullyAuthenticated(); - } - setIsAuthenticating(false); - }; - - const unlockWithKey = async () => { - setIsAuthenticating(true); - if (await startAndDecrypt()) { - ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); - successfullyAuthenticated(); - } else { - setIsAuthenticating(false); - } - }; - - const renderUnlockOptions = () => { - if (isAuthenticating) { - return ; - } else { - const color = colorScheme === 'dark' ? '#FFFFFF' : '#000000'; - if ((biometricType === Biometric.TouchID || biometricType === Biometric.Biometrics) && !isStorageEncryptedEnabled) { - return ( - - - - ); - } else if (biometricType === Biometric.FaceID && !isStorageEncryptedEnabled) { - return ( - - - - ); - } else if (isStorageEncryptedEnabled) { - return ( - - - - ); - } - } - }; - - const onAnimationFinish = async () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - if (unlockOnComponentMount) { - const storageIsEncrypted = await isStorageEncrypted(); - setIsStorageEncryptedEnabled(storageIsEncrypted); - if (!biometricType || storageIsEncrypted) { - unlockWithKey(); - } else if (typeof biometricType === 'string') unlockWithBiometrics(); - } - setAnimationDidFinish(true); - }; - - return ( - - - - - {animationDidFinish && {renderUnlockOptions()}} - - - ); -}; - -export default UnlockWith; diff --git a/WatchConnectivity.ios.js b/WatchConnectivity.ios.js deleted file mode 100644 index 6c21a16a876..00000000000 --- a/WatchConnectivity.ios.js +++ /dev/null @@ -1,227 +0,0 @@ -import { useContext, useEffect, useRef } from 'react'; -import { - updateApplicationContext, - watchEvents, - useReachability, - useInstalled, - usePaired, - transferCurrentComplicationUserInfo, -} from 'react-native-watch-connectivity'; -import { Chain } from './models/bitcoinUnits'; -import loc, { formatBalance, transactionTimeToReadable } from './loc'; -import { BlueStorageContext } from './blue_modules/storage-context'; -import Notifications from './blue_modules/notifications'; -import { FiatUnit } from './models/fiatUnit'; -import { MultisigHDWallet } from './class'; - -function WatchConnectivity() { - const { walletsInitialized, wallets, fetchWalletTransactions, saveToDisk, txMetadata, preferredFiatCurrency } = - useContext(BlueStorageContext); - const isReachable = useReachability(); - const isPaired = usePaired(); - const isInstalled = useInstalled(); // true | false - const messagesListenerActive = useRef(false); - const lastPreferredCurrency = useRef(FiatUnit.USD.endPointKey); - - useEffect(() => { - let messagesListener = () => {}; - if (isPaired && isInstalled && isReachable && walletsInitialized && messagesListenerActive.current === false) { - messagesListener = watchEvents.addListener('message', handleMessages); - messagesListenerActive.current = true; - } else { - messagesListener(); - messagesListenerActive.current = false; - } - return () => { - messagesListener(); - messagesListenerActive.current = false; - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized, isPaired, isReachable, isInstalled]); - - useEffect(() => { - if (isPaired && isInstalled && isReachable && walletsInitialized) { - sendWalletsToWatch(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [walletsInitialized, wallets, isPaired, isReachable, isInstalled]); - - useEffect(() => { - updateApplicationContext({ isWalletsInitialized: walletsInitialized, randomID: Math.floor(Math.random() * 11) }); - }, [walletsInitialized]); - - useEffect(() => { - if (isInstalled && isReachable && walletsInitialized && preferredFiatCurrency) { - const preferredFiatCurrencyParsed = JSON.parse(preferredFiatCurrency); - try { - if (lastPreferredCurrency.current !== preferredFiatCurrencyParsed.endPointKey) { - transferCurrentComplicationUserInfo({ - preferredFiatCurrency: preferredFiatCurrencyParsed.endPointKey, - }); - lastPreferredCurrency.current = preferredFiatCurrency.endPointKey; - } else { - console.log('WatchConnectivity lastPreferredCurrency has not changed'); - } - } catch (e) { - console.log('WatchConnectivity useEffect preferredFiatCurrency error'); - console.log(e); - } - } - }, [preferredFiatCurrency, walletsInitialized, isReachable, isInstalled]); - - const handleMessages = (message, reply) => { - if (message.request === 'createInvoice') { - handleLightningInvoiceCreateRequest(message.walletIndex, message.amount, message.description) - .then(createInvoiceRequest => reply({ invoicePaymentRequest: createInvoiceRequest })) - .catch(e => { - console.log(e); - reply({}); - }); - } else if (message.message === 'sendApplicationContext') { - sendWalletsToWatch(); - reply({}); - } else if (message.message === 'fetchTransactions') { - fetchWalletTransactions() - .then(() => saveToDisk()) - .finally(() => reply({})); - } else if (message.message === 'hideBalance') { - const walletIndex = message.walletIndex; - const wallet = wallets[walletIndex]; - wallet.hideBalance = message.hideBalance; - saveToDisk().finally(() => reply({})); - } - }; - - const handleLightningInvoiceCreateRequest = async (walletIndex, amount, description = loc.lnd.placeholder) => { - const wallet = wallets[walletIndex]; - if (wallet.allowReceive() && amount > 0) { - try { - const invoiceRequest = await wallet.addInvoice(amount, description); - - // lets decode payreq and subscribe groundcontrol so we can receive push notification when our invoice is paid - try { - // Let's verify if notifications are already configured. Otherwise the watch app will freeze waiting for user approval in iOS app - if (await Notifications.isNotificationsEnabled()) { - const decoded = await wallet.decodeInvoice(invoiceRequest); - Notifications.majorTomToGroundControl([], [decoded.payment_hash], []); - } - } catch (e) { - console.log('WatchConnectivity - Running in Simulator'); - console.log(e); - } - return invoiceRequest; - } catch (error) { - return error; - } - } - }; - - const sendWalletsToWatch = async () => { - if (!Array.isArray(wallets)) { - console.log('No Wallets set to sync with Watch app. Exiting...'); - return; - } - if (!walletsInitialized) { - console.log('Wallets not initialized. Exiting...'); - return; - } - const walletsToProcess = []; - - for (const wallet of wallets) { - let receiveAddress; - if (wallet.chain === Chain.ONCHAIN) { - try { - receiveAddress = await wallet.getAddressAsync(); - } catch (_) {} - if (!receiveAddress) { - // either sleep expired or getAddressAsync threw an exception - receiveAddress = wallet._getExternalAddressByIndex(wallet.next_free_address_index); - } - } else if (wallet.chain === Chain.OFFCHAIN) { - try { - await wallet.getAddressAsync(); - receiveAddress = wallet.getAddress(); - } catch (_) {} - if (!receiveAddress) { - // either sleep expired or getAddressAsync threw an exception - receiveAddress = wallet.getAddress(); - } - } - const transactions = wallet.getTransactions(10); - const watchTransactions = []; - for (const transaction of transactions) { - let type = 'pendingConfirmation'; - let memo = ''; - let amount = 0; - - if ('confirmations' in transaction && !(transaction.confirmations > 0)) { - type = 'pendingConfirmation'; - } else if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = transaction.timestamp + transaction.expire_time; - - if (invoiceExpiration > now) { - type = 'pendingConfirmation'; - } else if (invoiceExpiration < now) { - if (transaction.ispaid) { - type = 'received'; - } else { - type = 'sent'; - } - } - } else if (transaction.value / 100000000 < 0) { - type = 'sent'; - } else { - type = 'received'; - } - if (transaction.type === 'user_invoice' || transaction.type === 'payment_request') { - amount = isNaN(transaction.value) ? '0' : amount; - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = transaction.timestamp + transaction.expire_time; - - if (invoiceExpiration > now) { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } else if (invoiceExpiration < now) { - if (transaction.ispaid) { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } else { - amount = loc.lnd.expired; - } - } else { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } - } else { - amount = formatBalance(transaction.value, wallet.getPreferredBalanceUnit(), true).toString(); - } - if (txMetadata[transaction.hash] && txMetadata[transaction.hash].memo) { - memo = txMetadata[transaction.hash].memo; - } else if (transaction.memo) { - memo = transaction.memo; - } - const watchTX = { type, amount, memo, time: transactionTimeToReadable(transaction.received) }; - watchTransactions.push(watchTX); - } - - const walletInformation = { - label: wallet.getLabel(), - balance: formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), - type: wallet.type, - preferredBalanceUnit: wallet.getPreferredBalanceUnit(), - receiveAddress, - transactions: watchTransactions, - hideBalance: wallet.hideBalance, - }; - if (wallet.chain === Chain.ONCHAIN && wallet.type !== MultisigHDWallet.type) { - walletInformation.xpub = wallet.getXpub() ? wallet.getXpub() : wallet.getSecret(); - } - walletsToProcess.push(walletInformation); - } - updateApplicationContext({ wallets: walletsToProcess, randomID: Math.floor(Math.random() * 11) }); - }; - - return null; -} - -export default WatchConnectivity; diff --git a/WatchConnectivity.js b/WatchConnectivity.js deleted file mode 100644 index 93d040a1a59..00000000000 --- a/WatchConnectivity.js +++ /dev/null @@ -1,4 +0,0 @@ -const WatchConnectivity = () => { - return null; -}; -export default WatchConnectivity; diff --git a/__mocks__/@react-native-async-storage/async-storage.js b/__mocks__/@react-native-async-storage/async-storage.ts similarity index 100% rename from __mocks__/@react-native-async-storage/async-storage.js rename to __mocks__/@react-native-async-storage/async-storage.ts diff --git a/__mocks__/react-native-image-picker.js b/__mocks__/react-native-image-picker.js deleted file mode 100644 index 3392a0e6b3e..00000000000 --- a/__mocks__/react-native-image-picker.js +++ /dev/null @@ -1,13 +0,0 @@ -import {NativeModules} from 'react-native'; - -// Mock the ImagePickerManager native module to allow us to unit test the JavaScript code -NativeModules.ImagePickerManager = { - showImagePicker: jest.fn(), - launchCamera: jest.fn(), - launchImageLibrary: jest.fn(), -}; - -// Reset the mocks before each test -global.beforeEach(() => { - jest.resetAllMocks(); -}); diff --git a/__mocks__/react-native-image-picker.ts b/__mocks__/react-native-image-picker.ts new file mode 100644 index 00000000000..c401aabe35d --- /dev/null +++ b/__mocks__/react-native-image-picker.ts @@ -0,0 +1,13 @@ +import { NativeModules } from 'react-native'; + +// Mock the ImagePickerManager native module to allow us to unit test the JavaScript code +NativeModules.ImagePickerManager = { + showImagePicker: jest.fn(), + launchCamera: jest.fn(), + launchImageLibrary: jest.fn(), +}; + +// Reset the mocks before each test +global.beforeEach(() => { + jest.resetAllMocks(); +}); diff --git a/__mocks__/react-native-localize.js b/__mocks__/react-native-localize.ts similarity index 100% rename from __mocks__/react-native-localize.js rename to __mocks__/react-native-localize.ts diff --git a/__mocks__/react-native-tor.js b/__mocks__/react-native-tor.js deleted file mode 100644 index d4bf7deed45..00000000000 --- a/__mocks__/react-native-tor.js +++ /dev/null @@ -1,18 +0,0 @@ -/* global jest */ - -export const startIfNotStarted = jest.fn(async (key, value, callback) => { - return 666; -}); - - -export const get = jest.fn(); -export const post = jest.fn(); -export const deleteMock = jest.fn(); -export const stopIfRunning = jest.fn(); -export const getDaemonStatus = jest.fn(); - -const mock = jest.fn().mockImplementation(() => { - return { startIfNotStarted, get, post, delete: deleteMock, stopIfRunning, getDaemonStatus }; -}); - -export default mock; \ No newline at end of file diff --git a/android/.project b/android/.project index d22beed79e6..0117c3a8138 100644 --- a/android/.project +++ b/android/.project @@ -14,4 +14,15 @@ org.eclipse.buildship.core.gradleprojectnature + + + 1729710829465 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/android/.settings/org.eclipse.buildship.core.prefs b/android/.settings/org.eclipse.buildship.core.prefs deleted file mode 100644 index e8895216fd3..00000000000 --- a/android/.settings/org.eclipse.buildship.core.prefs +++ /dev/null @@ -1,2 +0,0 @@ -connection.project.dir= -eclipse.preferences.version=1 diff --git a/android/app/.classpath b/android/app/.classpath index eb19361b571..bbe97e501d3 100644 --- a/android/app/.classpath +++ b/android/app/.classpath @@ -1,6 +1,6 @@ - + diff --git a/android/app/.project b/android/app/.project index ac485d7c3e6..1a270d11d7b 100644 --- a/android/app/.project +++ b/android/app/.project @@ -20,4 +20,15 @@ org.eclipse.jdt.core.javanature org.eclipse.buildship.core.gradleprojectnature + + + 1729710829486 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + diff --git a/android/app/.settings/org.eclipse.jdt.core.prefs b/android/app/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 00000000000..626e0e1d5c6 --- /dev/null +++ b/android/app/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,4 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=17 +org.eclipse.jdt.core.compiler.compliance=17 +org.eclipse.jdt.core.compiler.source=17 diff --git a/android/app/build.gradle b/android/app/build.gradle index a1b77db54d2..64ba4466f3d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,29 +1,27 @@ apply plugin: "com.android.application" -apply plugin: "kotlin-android" +apply plugin: "org.jetbrains.kotlin.android" apply plugin: "com.facebook.react" -import com.android.build.OutputFile - /** * This is the configuration block to customize your React Native Android app. * By default you don't need to apply any configuration, just uncomment the lines you need. */ react { /* Folders */ - // The root of your project, i.e. where "package.json" lives. Default is '..' - // root = file("../") - // The folder where the react-native NPM package is. Default is ../node_modules/react-native - // reactNativeDir = file("../node_modules/react-native") - // The folder where the react-native Codegen package is. Default is ../node_modules/react-native-codegen - // codegenDir = file("../node_modules/react-native-codegen") - // The cli.js file which is the React Native CLI entrypoint. Default is ../node_modules/react-native/cli.js - // cliFile = file("../node_modules/react-native/cli.js") + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + // The cli.js file which is the React Native CLI entrypoint. Default is ../../node_modules/react-native/cli.js + // cliFile = file("../../node_modules/react-native/cli.js") /* Variants */ // The list of variants to that are debuggable. For those we're going to - // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // skip the bundling of the JS bundle and the assets. Default is "debug", "debugOptimized". // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. - // debuggableVariants = ["liteDebug", "prodDebug"] + // debuggableVariants = ["liteDebug", "liteDebugOptimized", "prodDebug", "prodDebugOptimized"] /* Bundling */ // A list containing the node command and its flags. Default is just 'node'. @@ -51,15 +49,10 @@ react { // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] -} -/** - * Set this to true to create four separate APKs instead of one, - * one for each native architecture. This is useful if you don't - * use App Bundles (https://developer.android.com/guide/app-bundle/) - * and want to have separate APKs to upload to the Play Store. - */ -def enableSeparateBuildPerCPUArchitecture = false + /* Autolinking */ + autolinkLibrariesWithApp() +} /** * Set this to true to Run Proguard on Release builds to minify the Java bytecode. @@ -70,90 +63,97 @@ def enableProguardInReleaseBuilds = false * The preferred build flavor of JavaScriptCore (JSC) * * For example, to use the international variant, you can use: - * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * `def jscFlavor = io.github.react-native-community:jsc-android-intl:2026004.0.1` * * The international variant includes ICU i18n library and necessary data * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that * give correct results when using with locales other than en-US. Note that * this variant is about 6MiB larger per architecture than default. */ -def jscFlavor = 'org.webkit:android-jsc-intl:+' - -/** - * Private function to get the list of Native Architectures you want to build. - * This reads the value from reactNativeArchitectures in your gradle.properties - * file and works together with the --active-arch-only flag of react-native run-android. - */ -def reactNativeArchitectures() { - def value = project.getProperties().get("reactNativeArchitectures") - return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"] -} +def jscFlavor = 'io.github.react-native-community:jsc-android:2026004.0.1' android { - ndkVersion rootProject.ext.ndkVersion + androidResources { + noCompress += ["bundle"] + } + ndkVersion rootProject.ext.ndkVersion + buildToolsVersion rootProject.ext.buildToolsVersion compileSdkVersion rootProject.ext.compileSdkVersion + namespace "io.bluewallet.bluewallet" defaultConfig { applicationId "io.bluewallet.bluewallet" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 - versionName "6.4.9" + versionName "8.0.2" testBuildType System.getProperty('testBuildType', 'debug') testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' + // Keep compatibility across react-native-capture-protection flavor changes. + missingDimensionStrategy "react-native-capture-protection", "callbackTiramisu", "base" } - splits { - abi { - reset() - enable enableSeparateBuildPerCPUArchitecture - universalApk false // If true, also generate a universal APK - include (*reactNativeArchitectures()) + lint { + abortOnError false + checkReleaseBuilds false + } + + sourceSets { + main { + assets.srcDirs = ['src/main/assets', 'src/main/res/assets'] + java.srcDirs = ['src/main/java', '../../blue_modules/Views/SegmentedControl/android'] } } + buildTypes { release { - // Caution! In production, you need to generate your own keystore file. - // see https://reactnative.dev/docs/signed-apk-android. minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" proguardFile "${rootProject.projectDir}/../node_modules/detox/android/detox/proguard-rules-app.pro" } } - // applicationVariants are e.g. debug, release - applicationVariants.all { variant -> - variant.outputs.each { output -> - // For each separate APK per architecture, set a unique version code as described here: - // https://developer.android.com/studio/build/configure-apk-splits.html - // Example: versionCode 1 will generate 1001 for armeabi-v7a, 1002 for x86, etc. - def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] - def abi = output.getFilter(OutputFile.ABI) - if (abi != null) { // null for the universal-debug, universal-release variants - output.versionCodeOverride = - defaultConfig.versionCode * 1000 + versionCodes.get(abi) - } + dependenciesInfo { + includeInApk = false + includeInBundle = false + } - } +} + +task copyFiatUnits(type: Copy) { + from '../../models/fiatUnits.json' + into 'src/main/assets' +} + +preBuild.dependsOn(copyFiatUnits) + +// Ensure fiat units are available before codegen scans JS sources +tasks.configureEach { task -> + if (task.name == 'generateCodegenSchemaFromJavaScript') { + task.dependsOn(copyFiatUnits) } } dependencies { + androidTestImplementation('com.wix:detox:20.51.4') // The version of react-native is set by the React Native Gradle Plugin implementation("com.facebook.react:react-android") - implementation files("../../node_modules/rn-ldk/android/libs/LDK-release.aar") - implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.0.0") - implementation files("../../node_modules/react-native-tor/android/libs/sifir_android.aar") + implementation 'androidx.core:core-ktx:1.18.0' + implementation 'androidx.work:work-runtime-ktx:2.11.2' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.compose.ui:ui:1.10.6' + implementation 'androidx.compose.material3:material3:1.3.2' + implementation 'androidx.preference:preference-ktx:1.2.1' if (hermesEnabled.toBoolean()) { implementation("com.facebook.react:hermes-android") } else { implementation jscFlavor } - androidTestImplementation('com.wix:detox:+') - implementation 'androidx.appcompat:appcompat:1.1.0' - implementation fileTree(dir: "libs", include: ["*.jar"]) + + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'androidx.constraintlayout:constraintlayout:2.2.2' } apply plugin: 'com.google.gms.google-services' // Google Services plugin -apply from: file("../../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesAppBuildGradle(project) \ No newline at end of file +apply plugin: "com.bugsnag.android.gradle" \ No newline at end of file diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index b964573e4e9..cb6ff29732e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -11,8 +11,5 @@ -keep class com.facebook.hermes.unicode.** { *; } -keep class com.facebook.jni.** { *; } --keep class com.sifir.** { *;} --keep interface com.sifir.** { *;} --keep enum com.sifir.** { *;} -keep class com.swmansion.reanimated.** { *; } -keep class com.facebook.react.turbomodule.** { *; } diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml index 0c4927bccd6..476d7dc1324 100644 --- a/android/app/src/debug/AndroidManifest.xml +++ b/android/app/src/debug/AndroidManifest.xml @@ -1,10 +1,6 @@ - - - - diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index feb5e9c0bb9..5768f67c2fa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,115 +1,184 @@ + xmlns:tools="http://schemas.android.com/tools" + android:installLocation="auto"> + + - - - + + + - - + + + + + - - + - - - - - - + + + + + + + + + + + + + + + - - - + - + + - + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - + - + \ No newline at end of file diff --git a/android/app/src/main/assets/fiatUnits.json b/android/app/src/main/assets/fiatUnits.json new file mode 100644 index 00000000000..cb113540610 --- /dev/null +++ b/android/app/src/main/assets/fiatUnits.json @@ -0,0 +1,443 @@ +{ + "USD": { + "endPointKey": "USD", + "locale": "en-US", + "source": "Kraken", + "symbol": "$", + "country": "United States (US Dollar)" + }, + "AED": { + "endPointKey": "AED", + "locale": "ar-AE", + "source": "CoinGecko", + "symbol": "د.إ.", + "country": "United Arab Emirates (UAE Dirham)" + }, + "AMD": { + "endPointKey": "AMD", + "locale": "hy-AM", + "source": "Coinbase", + "symbol": "֏", + "country": "Armenia (Armenian Dram)" + }, + "ANG": { + "endPointKey": "ANG", + "locale": "en-SX", + "source": "YadioConvert", + "symbol": "ƒ", + "country": "Sint Maarten (Netherlands Antillean Guilder)" + }, + "ARS": { + "endPointKey": "ARS", + "locale": "es-AR", + "source": "Yadio", + "symbol": "$", + "country": "Argentina (Argentine Peso)" + }, + "AUD": { + "endPointKey": "AUD", + "locale": "en-AU", + "source": "CoinGecko", + "symbol": "$", + "country": "Australia (Australian Dollar)" + }, + "AWG": { + "endPointKey": "AWG", + "locale": "nl-AW", + "source": "Coinbase", + "symbol": "ƒ", + "country": "Aruba (Aruban Florin)" + }, + "BHD": { + "endPointKey": "BHD", + "locale": "ar-BH", + "source": "CoinGecko", + "symbol": "د.ب.", + "country": "Bahrain (Bahraini Dinar)" + }, + "BRL": { + "endPointKey": "BRL", + "locale": "pt-BR", + "source": "CoinGecko", + "symbol": "R$", + "country": "Brazil (Brazilian Real)" + }, + "CAD": { + "endPointKey": "CAD", + "locale": "en-CA", + "source": "CoinGecko", + "symbol": "$", + "country": "Canada (Canadian Dollar)" + }, + "CHF": { + "endPointKey": "CHF", + "locale": "de-CH", + "source": "CoinGecko", + "symbol": "CHF", + "country": "Switzerland (Swiss Franc)" + }, + "CLP": { + "endPointKey": "CLP", + "locale": "es-CL", + "source": "Yadio", + "symbol": "$", + "country": "Chile (Chilean Peso)" + }, + "CNY": { + "endPointKey": "CNY", + "locale": "zh-CN", + "source": "Coinbase", + "symbol": "¥", + "country": "China (Chinese Yuan)" + }, + "COP": { + "endPointKey": "COP", + "locale": "es-CO", + "source": "CoinDesk", + "symbol": "$", + "country": "Colombia (Colombian Peso)" + }, + "CZK": { + "endPointKey": "CZK", + "locale": "cs-CZ", + "source": "CoinGecko", + "symbol": "Kč", + "country": "Czech Republic (Czech Koruna)" + }, + "DKK": { + "endPointKey": "DKK", + "locale": "da-DK", + "source": "CoinGecko", + "symbol": "kr", + "country": "Denmark (Danish Krone)" + }, + "EGP": { + "endPointKey": "EGP", + "locale": "ar-EG", + "source": "YadioConvert", + "symbol": "ج.م.", + "country": "Egypt (Egyptian Pound)" + }, + "EUR": { + "endPointKey": "EUR", + "locale": "en-IE", + "source": "Kraken", + "symbol": "€", + "country": "European Union (Euro)" + }, + "GBP": { + "endPointKey": "GBP", + "locale": "en-GB", + "source": "Kraken", + "symbol": "£", + "country": "United Kingdom (British Pound)" + }, + "HKD": { + "endPointKey": "HKD", + "locale": "zh-HK", + "source": "CoinGecko", + "symbol": "HK$", + "country": "Hong Kong (Hong Kong Dollar)" + }, + "HRK": { + "endPointKey": "HRK", + "locale": "hr-HR", + "source": "Coinbase", + "symbol": "HRK", + "country": "Croatia (Croatian Kuna)" + }, + "HUF": { + "endPointKey": "HUF", + "locale": "hu-HU", + "source": "CoinGecko", + "symbol": "Ft", + "country": "Hungary (Hungarian Forint)" + }, + "IDR": { + "endPointKey": "IDR", + "locale": "id-ID", + "source": "CoinGecko", + "symbol": "Rp", + "country": "Indonesia (Indonesian Rupiah)" + }, + "ILS": { + "endPointKey": "ILS", + "locale": "he-IL", + "source": "CoinGecko", + "symbol": "₪", + "country": "Israel (Israeli New Shekel)" + }, + "INR": { + "endPointKey": "INR", + "locale": "hi-IN", + "source": "coinpaprika", + "symbol": "₹", + "country": "India (Indian Rupee)" + }, + "IRR": { + "endPointKey": "IRR", + "locale": "fa-IR", + "source": "Exir", + "symbol": "﷼", + "country": "Iran (Iranian Rial)" + }, + "IRT": { + "endPointKey": "IRT", + "locale": "fa-IR", + "source": "Exir", + "symbol": "تومان", + "country": "Iran (Iranian Toman)" + }, + "ISK": { + "endPointKey": "ISK", + "locale": "is-IS", + "source": "Coinbase", + "symbol": "kr", + "country": "Iceland (Icelandic Króna)" + }, + "JPY": { + "endPointKey": "JPY", + "locale": "ja-JP", + "source": "CoinGecko", + "symbol": "¥", + "country": "Japan (Japanese Yen)" + }, + "KES": { + "endPointKey": "KES", + "locale": "en-KE", + "source": "CoinDesk", + "symbol": "Ksh", + "country": "Kenya (Kenyan Shilling)" + }, + "KRW": { + "endPointKey": "KRW", + "locale": "ko-KR", + "source": "CoinGecko", + "symbol": "₩", + "country": "South Korea (South Korean Won)" + }, + "KWD": { + "endPointKey": "KWD", + "locale": "ar-KW", + "source": "CoinGecko", + "symbol": "د.ك.", + "country": "Kuwait (Kuwaiti Dinar)" + }, + "LBP": { + "endPointKey": "LBP", + "locale": "ar-LB", + "source": "YadioConvert", + "symbol": "ل.ل.", + "country": "Lebanon (Lebanese Pound)" + }, + "LKR": { + "endPointKey": "LKR", + "locale": "si-LK", + "source": "CoinGecko", + "symbol": "රු.", + "country": "Sri Lanka (Sri Lankan Rupee)" + }, + "MXN": { + "endPointKey": "MXN", + "locale": "es-MX", + "source": "CoinGecko", + "symbol": "$", + "country": "Mexico (Mexican Peso)" + }, + "MYR": { + "endPointKey": "MYR", + "locale": "ms-MY", + "source": "CoinGecko", + "symbol": "RM", + "country": "Malaysia (Malaysian Ringgit)" + }, + "MZN": { + "endPointKey": "MZN", + "locale": "seh-MZ", + "source": "Coinbase", + "symbol": "MTn", + "country": "Mozambique (Mozambican Metical)" + }, + "NGN": { + "endPointKey": "NGN", + "locale": "en-NG", + "source": "CoinGecko", + "symbol": "₦", + "country": "Nigeria (Nigerian Naira)" + }, + "NOK": { + "endPointKey": "NOK", + "locale": "nb-NO", + "source": "CoinGecko", + "symbol": "kr", + "country": "Norway (Norwegian Krone)" + }, + "NZD": { + "endPointKey": "NZD", + "locale": "en-NZ", + "source": "CoinGecko", + "symbol": "$", + "country": "New Zealand (New Zealand Dollar)" + }, + "OMR": { + "endPointKey": "OMR", + "locale": "ar-OM", + "source": "Coinbase", + "symbol": "ر.ع.", + "country": "Oman (Omani Rial)" + }, + "PHP": { + "endPointKey": "PHP", + "locale": "en-PH", + "source": "CoinGecko", + "symbol": "₱", + "country": "Philippines (Philippine Peso)" + }, + "PLN": { + "endPointKey": "PLN", + "locale": "pl-PL", + "source": "CoinGecko", + "symbol": "zł", + "country": "Poland (Polish Zloty)" + }, + "PYG": { + "endPointKey": "PYG", + "locale": "es-PY", + "source": "Coinbase", + "symbol": "₲", + "country": "Paraguay (Paraguayan Guarani)" + }, + "QAR": { + "endPointKey": "QAR", + "locale": "ar-QA", + "source": "Coinbase", + "symbol": "ر.ق.", + "country": "Qatar (Qatari Riyal)" + }, + "RON": { + "endPointKey": "RON", + "locale": "ro-RO", + "source": "BNR", + "symbol": "lei", + "country": "Romania (Romanian Leu)" + }, + "RSD": { + "endPointKey": "RSD", + "locale": "sr-RS", + "source": "Coinbase", + "symbol": "DIN", + "country": "Serbia (Serbian Dinar)" + }, + "RUB": { + "endPointKey": "RUB", + "locale": "ru-RU", + "source": "CoinGecko", + "symbol": "₽", + "country": "Russia (Russian Ruble)" + }, + "SAR": { + "endPointKey": "SAR", + "locale": "ar-SA", + "source": "CoinGecko", + "symbol": "ر.س.", + "country": "Saudi Arabia (Saudi Riyal)" + }, + "SEK": { + "endPointKey": "SEK", + "locale": "sv-SE", + "source": "CoinGecko", + "symbol": "kr", + "country": "Sweden (Swedish Krona)" + }, + "SGD": { + "endPointKey": "SGD", + "locale": "zh-SG", + "source": "CoinGecko", + "symbol": "S$", + "country": "Singapore (Singapore Dollar)" + }, + "THB": { + "endPointKey": "THB", + "locale": "th-TH", + "source": "CoinGecko", + "symbol": "฿", + "country": "Thailand (Thai Baht)" + }, + "TRY": { + "endPointKey": "TRY", + "locale": "tr-TR", + "source": "CoinGecko", + "symbol": "₺", + "country": "Turkey (Turkish Lira)" + }, + "TWD": { + "endPointKey": "TWD", + "locale": "zh-Hant-TW", + "source": "CoinGecko", + "symbol": "NT$", + "country": "Taiwan (New Taiwan Dollar)" + }, + "TZS": { + "endPointKey": "TZS", + "locale": "en-TZ", + "source": "Coinbase", + "symbol": "TSh", + "country": "Tanzania (Tanzanian Shilling)" + }, + "UAH": { + "endPointKey": "UAH", + "locale": "uk-UA", + "source": "CoinGecko", + "symbol": "₴", + "country": "Ukraine (Ukrainian Hryvnia)" + }, + "UGX": { + "endPointKey": "UGX", + "locale": "en-UG", + "source": "Coinbase", + "symbol": "USh", + "country": "Uganda (Ugandan Shilling)" + }, + "UYU": { + "endPointKey": "UYU", + "locale": "es-UY", + "source": "Coinbase", + "symbol": "$", + "country": "Uruguay (Uruguayan Peso)" + }, + "VEF": { + "endPointKey": "VEF", + "locale": "es-VE", + "source": "CoinGecko", + "symbol": "Bs.", + "country": "Venezuela (Venezuelan Bolívar Fuerte)" + }, + "VES": { + "endPointKey": "VES", + "locale": "es-VE", + "source": "Yadio", + "symbol": "Bs.", + "country": "Venezuela (Venezuelan Bolívar Soberano)" + }, + "XAF": { + "endPointKey": "XAF", + "locale": "fr-CF", + "source": "Coinbase", + "symbol": "Fr", + "country": "Central African Republic (Central African Franc)" + }, + "ZAR": { + "endPointKey": "ZAR", + "locale": "en-ZA", + "source": "CoinGecko", + "symbol": "R", + "country": "South Africa (South African Rand)" + }, + "GHS": { + "endPointKey": "GHS", + "locale": "en-GH", + "source": "Coinbase", + "symbol": "₵", + "country": "Ghana (Ghanaian Cedi)" + } +} \ No newline at end of file diff --git a/android/app/src/main/assets/fonts/AntDesign.ttf b/android/app/src/main/assets/fonts/AntDesign.ttf deleted file mode 100644 index 2abf03542c1..00000000000 Binary files a/android/app/src/main/assets/fonts/AntDesign.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Entypo.ttf b/android/app/src/main/assets/fonts/Entypo.ttf deleted file mode 100644 index 1c8f5e910bf..00000000000 Binary files a/android/app/src/main/assets/fonts/Entypo.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/EvilIcons.ttf b/android/app/src/main/assets/fonts/EvilIcons.ttf deleted file mode 100644 index 6868f7bb64b..00000000000 Binary files a/android/app/src/main/assets/fonts/EvilIcons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Feather.ttf b/android/app/src/main/assets/fonts/Feather.ttf deleted file mode 100755 index fc963dfe229..00000000000 Binary files a/android/app/src/main/assets/fonts/Feather.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/FontAwesome.ttf b/android/app/src/main/assets/fonts/FontAwesome.ttf deleted file mode 100644 index 35acda2fa11..00000000000 Binary files a/android/app/src/main/assets/fonts/FontAwesome.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf b/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf deleted file mode 100644 index 5f72e9127ff..00000000000 Binary files a/android/app/src/main/assets/fonts/FontAwesome5_Brands.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf b/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf deleted file mode 100644 index a309313d5fb..00000000000 Binary files a/android/app/src/main/assets/fonts/FontAwesome5_Regular.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf b/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf deleted file mode 100644 index 7ece3282a4f..00000000000 Binary files a/android/app/src/main/assets/fonts/FontAwesome5_Solid.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Foundation.ttf b/android/app/src/main/assets/fonts/Foundation.ttf deleted file mode 100644 index 6cce217ddc2..00000000000 Binary files a/android/app/src/main/assets/fonts/Foundation.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Ionicons.ttf b/android/app/src/main/assets/fonts/Ionicons.ttf deleted file mode 100644 index 67bd84202ad..00000000000 Binary files a/android/app/src/main/assets/fonts/Ionicons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf b/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf deleted file mode 100644 index 3219fca04a2..00000000000 Binary files a/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/MaterialIcons.ttf b/android/app/src/main/assets/fonts/MaterialIcons.ttf deleted file mode 100644 index 7015564ad16..00000000000 Binary files a/android/app/src/main/assets/fonts/MaterialIcons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Octicons.ttf b/android/app/src/main/assets/fonts/Octicons.ttf deleted file mode 100644 index ceac75d75e6..00000000000 Binary files a/android/app/src/main/assets/fonts/Octicons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/SimpleLineIcons.ttf b/android/app/src/main/assets/fonts/SimpleLineIcons.ttf deleted file mode 100644 index 6ecb6868347..00000000000 Binary files a/android/app/src/main/assets/fonts/SimpleLineIcons.ttf and /dev/null differ diff --git a/android/app/src/main/assets/fonts/Zocial.ttf b/android/app/src/main/assets/fonts/Zocial.ttf deleted file mode 100644 index e4ae46c6286..00000000000 Binary files a/android/app/src/main/assets/fonts/Zocial.ttf and /dev/null differ diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/AppWidgetUtils.kt b/android/app/src/main/java/io/bluewallet/bluewallet/AppWidgetUtils.kt new file mode 100644 index 00000000000..1473f67669d --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/AppWidgetUtils.kt @@ -0,0 +1,115 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi + +object AppWidgetUtils { + private const val TAG = "AppWidgetUtils" + + /** + * Get all Bitcoin Price Widget IDs + */ + fun getBitcoinPriceWidgetIds(context: Context): IntArray { + val appWidgetManager = AppWidgetManager.getInstance(context) + val component = ComponentName(context, BitcoinPriceWidget::class.java) + return appWidgetManager.getAppWidgetIds(component) + } + + /** + * Trigger update for all widgets when theme changes + */ + fun updateWidgetsForThemeChange(context: Context) { + Log.d(TAG, "Updating widgets for theme change") + + // Update Bitcoin Price widgets - force a complete refresh + val bitcoinWidgetIds = getBitcoinPriceWidgetIds(context) + if (bitcoinWidgetIds.isNotEmpty()) { + Log.d(TAG, "Refreshing ${bitcoinWidgetIds.size} Bitcoin Price widgets") + for (widgetId in bitcoinWidgetIds) { + BitcoinPriceWidget.refreshWidget(context, widgetId) + } + } + + // Update Market widgets + val marketWidgetIds = MarketWidget.getAllWidgetIds(context) + if (marketWidgetIds.isNotEmpty()) { + Log.d(TAG, "Refreshing ${marketWidgetIds.size} Market widgets") + MarketWidget.refreshAllWidgetsImmediately(context) + } + } + + /** + * Check if app widgets are supported and available on this device + */ + fun isWidgetAvailable(context: Context): Boolean { + val appWidgetManager = AppWidgetManager.getInstance(context) + return appWidgetManager != null + } + + /** + * Request to pin a widget to the home screen (Android 8.0+) + */ + @RequiresApi(Build.VERSION_CODES.O) + fun requestPinBitcoinWidget(context: Context): Boolean { + val appWidgetManager = AppWidgetManager.getInstance(context) + if (!appWidgetManager.isRequestPinAppWidgetSupported) { + Log.w(TAG, "Pin widget not supported on this device") + return false + } + + val myProvider = ComponentName(context, BitcoinPriceWidget::class.java) + return try { + appWidgetManager.requestPinAppWidget(myProvider, null, null) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to request pin widget", e) + false + } + } + + /** + * Request to pin a market widget to the home screen (Android 8.0+) + */ + @RequiresApi(Build.VERSION_CODES.O) + fun requestPinMarketWidget(context: Context): Boolean { + val appWidgetManager = AppWidgetManager.getInstance(context) + if (!appWidgetManager.isRequestPinAppWidgetSupported) { + Log.w(TAG, "Pin widget not supported on this device") + return false + } + + val myProvider = ComponentName(context, MarketWidget::class.java) + return try { + appWidgetManager.requestPinAppWidget(myProvider, null, null) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to request pin widget", e) + false + } + } + + /** + * Refresh all widgets by triggering updates + */ + fun refreshAllWidgets(context: Context) { + Log.d(TAG, "Refreshing all widgets") + + // Refresh Bitcoin Price widgets + val bitcoinWidgetIds = getBitcoinPriceWidgetIds(context) + if (bitcoinWidgetIds.isNotEmpty()) { + for (widgetId in bitcoinWidgetIds) { + BitcoinPriceWidget.refreshWidget(context, widgetId) + } + } + + // Refresh Market widgets + val marketWidgetIds = MarketWidget.getAllWidgetIds(context) + if (marketWidgetIds.isNotEmpty()) { + MarketWidget.refreshAllWidgetsImmediately(context) + } + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt new file mode 100644 index 00000000000..70752e362a7 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/BitcoinPriceWidget.kt @@ -0,0 +1,129 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.work.WorkManager + +class BitcoinPriceWidget : AppWidgetProvider() { + + companion object { + private const val TAG = "BitcoinPriceWidget" + private const val SHARED_PREF_NAME = "group.io.bluewallet.bluewallet" + + fun updateNetworkStatus(context: Context, appWidgetIds: IntArray) { + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + val appWidgetManager = AppWidgetManager.getInstance(context) + + for (appWidgetId in appWidgetIds) { + val views = RemoteViews(context.packageName, R.layout.widget_layout) + views.setViewVisibility(R.id.network_status, if (isNetworkAvailable) View.GONE else View.VISIBLE) + appWidgetManager.partiallyUpdateAppWidget(appWidgetId, views) + } + } + + fun refreshWidget(context: Context, appWidgetId: Int) { + val appWidgetManager = AppWidgetManager.getInstance(context) + + // Create new RemoteViews to ensure it picks up current theme + val views = RemoteViews(context.packageName, R.layout.widget_layout) + + // Set network status + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + views.setViewVisibility(R.id.network_status, if (isNetworkAvailable) View.GONE else View.VISIBLE) + + // Try to load cached data first + val sharedPref = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val cachedPrice = sharedPref.getString("previous_price", null) + val preferredCurrency = sharedPref.getString("preferredCurrency", "USD") + val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) + + if (cachedPrice != null) { + // Show cached data immediately + try { + val locale = preferredCurrencyLocale + ?.let { runCatching { java.util.Locale.forLanguageTag(it) }.getOrNull() } + ?.takeIf { it.language.isNotBlank() } + ?: java.util.Locale.getDefault() + + val currencyFormat = java.text.NumberFormat.getCurrencyInstance(locale) + val currency = java.util.Currency.getInstance(preferredCurrency ?: "USD") + currencyFormat.currency = currency + currencyFormat.maximumFractionDigits = 0 + + val parsedCached = cachedPrice.toDoubleOrNull()?.toInt() + + views.setViewVisibility(R.id.loading_indicator, View.GONE) + views.setViewVisibility(R.id.price_arrow_container, View.GONE) + + if (parsedCached != null) { + views.setViewVisibility(R.id.price_value, View.VISIBLE) + views.setViewVisibility(R.id.last_updated_label, View.VISIBLE) + views.setViewVisibility(R.id.last_updated_time, View.VISIBLE) + views.setTextViewText(R.id.price_value, currencyFormat.format(parsedCached)) + views.setTextViewText( + R.id.last_updated_time, + java.text.SimpleDateFormat("hh:mm a", java.util.Locale.getDefault()).format(java.util.Date()) + ) + } else { + // If parsing fails, show loading state + views.setViewVisibility(R.id.price_value, View.GONE) + views.setViewVisibility(R.id.last_updated_label, View.GONE) + views.setViewVisibility(R.id.last_updated_time, View.GONE) + views.setViewVisibility(R.id.loading_indicator, View.VISIBLE) + } + } catch (e: Exception) { + Log.e(TAG, "Error displaying cached price", e) + // Show loading state if cache display fails + views.setViewVisibility(R.id.loading_indicator, View.VISIBLE) + views.setViewVisibility(R.id.price_value, View.GONE) + views.setViewVisibility(R.id.last_updated_label, View.GONE) + views.setViewVisibility(R.id.last_updated_time, View.GONE) + views.setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } else { + // No cached data, show loading state + views.setViewVisibility(R.id.loading_indicator, View.VISIBLE) + views.setViewVisibility(R.id.price_value, View.GONE) + views.setViewVisibility(R.id.last_updated_label, View.GONE) + views.setViewVisibility(R.id.last_updated_time, View.GONE) + views.setViewVisibility(R.id.price_arrow_container, View.GONE) + } + + appWidgetManager.updateAppWidget(appWidgetId, views) + WidgetUpdateWorker.scheduleImmediateUpdate(context) + WidgetUpdateWorker.scheduleWork(context) + } + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + + for (widgetId in appWidgetIds) { + Log.d(TAG, "Updating widget with ID: $widgetId") + refreshWidget(context, widgetId) + } + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + WidgetUpdateWorker.scheduleImmediateUpdate(context) + WidgetUpdateWorker.scheduleWork(context) + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE).edit().clear().apply() + WorkManager.getInstance(context).cancelUniqueWork(WidgetUpdateWorker.WORK_NAME) + } + + override fun onAppWidgetOptionsChanged(context: Context, appWidgetManager: AppWidgetManager, + appWidgetId: Int, newOptions: Bundle?) { + super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions) + refreshWidget(context, appWidgetId) + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/ElectrumClient.kt b/android/app/src/main/java/io/bluewallet/bluewallet/ElectrumClient.kt new file mode 100644 index 00000000000..7370fd0d05b --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/ElectrumClient.kt @@ -0,0 +1,348 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import org.json.JSONObject +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStream +import java.net.Socket +import java.net.SocketTimeoutException +import java.security.SecureRandom +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +class ElectrumClient { + companion object { + private const val TAG = "ElectrumClient" + private const val MAX_RETRIES = 3 + private const val RETRY_DELAY_MS = 1000L // 1 second delay between retries + + // Default list of Electrum servers to try + val hardcodedPeers = listOf( + ElectrumServer("electrum1.bluewallet.io", 50001, false), + ElectrumServer("electrum2.bluewallet.io", 50001, false), + ElectrumServer("electrum3.bluewallet.io", 50001, false), + ElectrumServer("electrum1.bluewallet.io", 443, true), + ElectrumServer("electrum2.bluewallet.io", 443, true), + ElectrumServer("electrum3.bluewallet.io", 443, true) + ) + } + + private var socket: Socket? = null + private var outputStream: OutputStream? = null + private var inputReader: BufferedReader? = null + private var context: Context? = null + private var networkStatusListener: NetworkStatusListener? = null + + data class ElectrumServer(val host: String, val port: Int, val isSsl: Boolean) + + /** + * Initialize ElectrumClient with application context for network checks + */ + fun initialize(context: Context) { + Log.i(TAG, "Initializing ElectrumClient with context") + this.context = context + } + + /** + * Set a listener for network status changes + */ + fun setNetworkStatusListener(listener: NetworkStatusListener) { + Log.d(TAG, "Setting network status listener") + this.networkStatusListener = listener + } + + /** + * Interface for listening to network status changes + */ + interface NetworkStatusListener { + fun onNetworkStatusChanged(isConnected: Boolean) + fun onConnectionError(error: String) + fun onConnectionSuccess() + } + + /** + * Check if the device has network connectivity + */ + private fun isNetworkAvailable(): Boolean { + val hasNetwork = context?.let { NetworkUtils.isNetworkAvailable(it) } ?: false + Log.d(TAG, "Network available: $hasNetwork") + return hasNetwork + } + + /** + * Connect to the next available Electrum server with network checks + */ + suspend fun connectToNextAvailable( + servers: List = hardcodedPeers, + validateCertificates: Boolean = true, + connectTimeout: Long = 5000 // 5 seconds + ): Boolean = withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + Log.i(TAG, "Starting connection attempt to Electrum server. Server count: ${servers.size}") + + // Check network availability first + if (!isNetworkAvailable()) { + Log.e(TAG, "No network connection available. Connection attempt aborted.") + networkStatusListener?.onNetworkStatusChanged(false) + return@withContext false + } + + var connected = false + var lastError: Exception? = null + + for (serverIndex in servers.indices) { + val server = servers[serverIndex] + if (connected) break + + Log.d(TAG, "Trying server ${serverIndex+1}/${servers.size}: ${server.host}:${server.port} (SSL: ${server.isSsl})") + + // Try up to MAX_RETRIES times per server + for (attempt in 1..MAX_RETRIES) { + try { + Log.d(TAG, "Connection attempt $attempt/$MAX_RETRIES to ${server.host}:${server.port} (SSL: ${server.isSsl})") + val attemptStartTime = System.currentTimeMillis() + + withTimeout(connectTimeout) { + if (connect(server, validateCertificates)) { + val attemptDuration = System.currentTimeMillis() - attemptStartTime + Log.i(TAG, "Successfully connected to ${server.host}:${server.port} in ${attemptDuration}ms") + networkStatusListener?.onConnectionSuccess() + connected = true + } else { + Log.w(TAG, "Failed to connect to ${server.host}:${server.port} - connect() returned false") + } + } + } catch (e: TimeoutCancellationException) { + lastError = e + Log.e(TAG, "Connection to ${server.host}:${server.port} timed out after ${connectTimeout}ms (attempt $attempt)") + if (attempt < MAX_RETRIES) { + Log.d(TAG, "Retrying after ${RETRY_DELAY_MS}ms delay") + delay(RETRY_DELAY_MS) + } + } catch (e: Exception) { + lastError = e + Log.e(TAG, "Error connecting to ${server.host}:${server.port} (attempt $attempt): ${e.message}") + if (attempt < MAX_RETRIES) { + Log.d(TAG, "Retrying after ${RETRY_DELAY_MS}ms delay") + delay(RETRY_DELAY_MS) + } + } + } + } + + val totalDuration = System.currentTimeMillis() - startTime + + if (!connected) { + Log.e(TAG, "Failed to connect to any Electrum server after ${totalDuration}ms. Last error: ${lastError?.message}") + networkStatusListener?.onConnectionError("Failed to connect to any Electrum server: ${lastError?.message}") + } else { + Log.i(TAG, "Successfully connected to an Electrum server in ${totalDuration}ms") + } + + connected + } + + /** + * Log the server details upon successful connection + */ + private fun logServerDetails(server: ElectrumServer) { + Log.i(TAG, "Connected to Electrum server: ${server.host}:${server.port} (SSL: ${server.isSsl})") + } + + /** + * Connect to a specific Electrum server with network check + */ + suspend fun connect( + server: ElectrumServer, + validateCertificates: Boolean = true + ): Boolean = withContext(Dispatchers.IO) { + val startTime = System.currentTimeMillis() + Log.d(TAG, "Attempting direct connection to ${server.host}:${server.port} (SSL: ${server.isSsl})") + + var result = false + + if (!isNetworkAvailable()) { + Log.e(TAG, "Cannot connect to ${server.host}: No network connection available") + networkStatusListener?.onNetworkStatusChanged(false) + return@withContext false + } + + try { + close() // Close any existing connection + Log.d(TAG, "Creating ${if (server.isSsl) "SSL " else ""}socket to ${server.host}:${server.port}") + + socket = if (server.isSsl) { + createSslSocket(server.host, server.port, validateCertificates) + } else { + Socket(server.host, server.port) + } + + Log.d(TAG, "Socket created successfully. Setting timeout and getting streams.") + socket?.soTimeout = 10000 // 10 seconds read timeout + outputStream = socket?.getOutputStream() + inputReader = BufferedReader(InputStreamReader(socket?.getInputStream())) + + // Testing the connection with simple version request + val versionRequest = "{\"id\": 0, \"method\": \"server.version\", \"params\": [\"BlueWallet\", \"1.4\"]}\n" + Log.d(TAG, "Sending version request to verify connection") + send(versionRequest.toByteArray()) + + val response = receive() + if (response.isNotEmpty()) { + val responseStr = String(response) + Log.d(TAG, "Received server version response: $responseStr") + networkStatusListener?.onNetworkStatusChanged(true) + logServerDetails(server) // Log server details here + result = true + } else { + Log.w(TAG, "Empty response from server when verifying connection") + networkStatusListener?.onConnectionError("Empty response from server") + close() + } + } catch (e: Exception) { + Log.e(TAG, "Error connecting to Electrum server: ${e.javaClass.simpleName} - ${e.message}") + networkStatusListener?.onConnectionError("Error connecting: ${e.message}") + close() + } + + val duration = System.currentTimeMillis() - startTime + Log.d(TAG, "Connection attempt to ${server.host}:${server.port} completed in ${duration}ms, result: $result") + + result + } + + /** + * Send data to the connected Electrum server with network check + */ + suspend fun send(data: ByteArray): Boolean = withContext(Dispatchers.IO) { + val message = String(data).trim() + val messagePreview = if (message.length > 100) message.substring(0, 100) + "..." else message + Log.d(TAG, "Sending to Electrum: $messagePreview") + + if (!isNetworkAvailable()) { + Log.e(TAG, "Cannot send data: No network connection available") + networkStatusListener?.onNetworkStatusChanged(false) + return@withContext false + } + + try { + outputStream?.write(data) + outputStream?.flush() + Log.d(TAG, "Data sent successfully") + return@withContext true + } catch (e: Exception) { + Log.e(TAG, "Error sending data to Electrum server: ${e.javaClass.simpleName} - ${e.message}") + networkStatusListener?.onConnectionError("Error sending data: ${e.message}") + return@withContext false + } + } + + /** + * Receive data from the connected Electrum server with timeout handling + */ + suspend fun receive(): ByteArray = withContext(Dispatchers.IO) { + Log.d(TAG, "Waiting to receive data from Electrum server") + val startTime = System.currentTimeMillis() + + try { + val response = StringBuilder() + var line: String? = null + + try { + while (inputReader?.readLine()?.also { line = it } != null) { + response.append(line) + // Break after receiving a complete JSON object + if (line?.contains("}") == true) { + break + } + } + } catch (e: SocketTimeoutException) { + Log.e(TAG, "Socket read timed out after ${System.currentTimeMillis() - startTime}ms") + networkStatusListener?.onConnectionError("Socket read timed out") + } + + val responseData = response.toString().toByteArray() + val responsePreview = if (response.length > 100) response.substring(0, 100) + "..." else response.toString() + + if (responseData.isNotEmpty()) { + val duration = System.currentTimeMillis() - startTime + Log.d(TAG, "Received data (${responseData.size} bytes) in ${duration}ms: $responsePreview") + } else { + Log.w(TAG, "Received empty response from Electrum server") + } + + return@withContext responseData + } catch (e: Exception) { + Log.e(TAG, "Error receiving data from Electrum server: ${e.javaClass.simpleName} - ${e.message}") + networkStatusListener?.onConnectionError("Error receiving data: ${e.message}") + return@withContext ByteArray(0) + } + } + + /** + * Close the connection to the Electrum server + */ + fun close() { + try { + inputReader?.close() + outputStream?.close() + socket?.close() + } catch (e: Exception) { + Log.e(TAG, "Error closing Electrum connection", e) + } finally { + inputReader = null + outputStream = null + socket = null + } + } + + /** + * Create an SSL socket with optional certificate validation + */ + private fun createSslSocket(host: String, port: Int, validateCertificates: Boolean): SSLSocket { + val sslContext = SSLContext.getInstance("TLS") + + if (!validateCertificates) { + val trustAllCerts = arrayOf(object : X509TrustManager { + override fun getAcceptedIssuers(): Array = arrayOf() + override fun checkClientTrusted(certs: Array, authType: String) {} + override fun checkServerTrusted(certs: Array, authType: String) {} + }) + + sslContext.init(null, trustAllCerts, SecureRandom()) + } else { + sslContext.init(null, null, null) + } + + val factory: SSLSocketFactory = sslContext.socketFactory + return factory.createSocket(host, port) as SSLSocket + } + + private fun getNextPeer(): ElectrumServer { + val savedPeer = getSavedPeer() + return if (savedPeer != null) { + Log.d(TAG, "Using saved peer: ${savedPeer.host}:${savedPeer.port} (SSL: ${savedPeer.isSsl})") + savedPeer + } else { + Log.d(TAG, "No saved peer found. Using default hardcoded peers.") + hardcodedPeers.random() + } + } + + + private fun getSavedPeer(): ElectrumServer? { + // implement later + return null + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java deleted file mode 100644 index 06101f38431..00000000000 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.java +++ /dev/null @@ -1,50 +0,0 @@ -package io.bluewallet.bluewallet; - -import android.content.pm.ActivityInfo; -import android.os.Bundle; -import android.os.PersistableBundle; - -import androidx.annotation.Nullable; - -import com.facebook.react.ReactActivity; - -import com.facebook.react.ReactActivityDelegate; -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; -import com.facebook.react.defaults.DefaultReactActivityDelegate; - -public class MainActivity extends ReactActivity { - - /** - * Returns the name of the main component registered from JavaScript. - * This is used to schedule rendering of the component. - */ - @Override - protected String getMainComponentName() { - return "BlueWallet"; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(null); - if (getResources().getBoolean(R.bool.portrait_only)) { - setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); - } - } - - /** - * Returns the instance of the {@link ReactActivityDelegate}. Here we use a util class {@link - * DefaultReactActivityDelegate} which allows you to easily enable Fabric and Concurrent React - * (aka React 18) with two boolean flags. - */ - @Override - protected ReactActivityDelegate createReactActivityDelegate() { - return new DefaultReactActivityDelegate( - this, - getMainComponentName(), - // If you opted-in for the New Architecture, we enable the Fabric Renderer. - DefaultNewArchitectureEntryPoint.getFabricEnabled(), // fabricEnabled - // If you opted-in for the New Architecture, we enable Concurrent React (i.e. React 18). - DefaultNewArchitectureEntryPoint.getConcurrentReactEnabled() // concurrentRootEnabled - ); - } -} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt new file mode 100644 index 00000000000..ad2d173a13b --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MainActivity.kt @@ -0,0 +1,72 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.content.pm.ActivityInfo +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.util.Log +import androidx.appcompat.app.AlertDialog +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint +import com.facebook.react.defaults.DefaultReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.swmansion.rnscreens.fragment.restoration.RNScreensFragmentFactory + +class MainActivity : ReactActivity() { + + /** + * Returns the name of the main component registered from JavaScript. + * This is used to schedule rendering of the component. + */ + override fun getMainComponentName(): String { + return "BlueWallet" + } + + override fun onCreate(savedInstanceState: Bundle?) { + // react-native-screens override + supportFragmentManager.fragmentFactory = RNScreensFragmentFactory() + super.onCreate(null) + if (resources.getBoolean(R.bool.portrait_only)) { + requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + } + + override fun onResume() { + super.onResume() + Log.d("MainActivity", "MainActivity resumed. Confirming single instance is active.") + + // Check if we should show cache cleared alert + checkAndShowCacheClearedAlert() + } + + private fun checkAndShowCacheClearedAlert() { + val sharedPref = getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) + val shouldShowAlert = sharedPref.getBoolean("shouldShowCacheClearedAlert", false) + + if (shouldShowAlert) { + // Reset the flag + sharedPref.edit() + .putBoolean("shouldShowCacheClearedAlert", false) + .apply() + + // Show alert after a short delay to ensure UI is ready + Handler(Looper.getMainLooper()).postDelayed({ + AlertDialog.Builder(this) + .setTitle(R.string.cache_cleared_title) + .setMessage(R.string.cache_cleared_message) + .setPositiveButton(android.R.string.ok, null) + .show() + }, 500) + } + } + + /** + * Returns the instance of the [ReactActivityDelegate]. Here we use a util class [DefaultReactActivityDelegate] + * which allows you to easily enable Fabric and Concurrent React (aka React 18) with two boolean flags. + */ + + override fun createReactActivityDelegate(): ReactActivityDelegate = + DefaultReactActivityDelegate(this, mainComponentName, fabricEnabled) +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java deleted file mode 100644 index 29dc4e9b6d6..00000000000 --- a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.java +++ /dev/null @@ -1,69 +0,0 @@ -package io.bluewallet.bluewallet; - -import android.app.Application; -import android.content.Context; -import com.facebook.react.PackageList; -import com.facebook.react.ReactApplication; -import com.facebook.react.ReactInstanceManager; -import com.facebook.react.ReactNativeHost; -import com.facebook.react.ReactPackage; -import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint; -import com.facebook.react.defaults.DefaultReactNativeHost; -import com.facebook.soloader.SoLoader; -import java.lang.reflect.InvocationTargetException; -import com.facebook.react.modules.i18nmanager.I18nUtil; -import java.util.List; -import com.bugsnag.android.Bugsnag; - -public class MainApplication extends Application implements ReactApplication { - - private final ReactNativeHost mReactNativeHost = - new DefaultReactNativeHost(this) { - @Override - public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; - } - - @Override - protected List getPackages() { - @SuppressWarnings("UnnecessaryLocalVariable") - List packages = new PackageList(this).getPackages(); - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - return packages; - } - - @Override - protected String getJSMainModuleName() { - return "index"; - } - - @Override - protected boolean isNewArchEnabled() { - return BuildConfig.IS_NEW_ARCHITECTURE_ENABLED; - } - - @Override - protected Boolean isHermesEnabled() { - return BuildConfig.IS_HERMES_ENABLED; - } - }; - - @Override - public ReactNativeHost getReactNativeHost() { - return mReactNativeHost; - } - - @Override - public void onCreate() { - super.onCreate(); - Bugsnag.start(this); - I18nUtil sharedI18nUtilInstance = I18nUtil.getInstance(); - sharedI18nUtilInstance.allowRTL(getApplicationContext(), true); - SoLoader.init(this, /* native exopackage */ false); - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - DefaultNewArchitectureEntryPoint.load(); - } - } -} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt new file mode 100644 index 00000000000..2a15859f32c --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MainApplication.kt @@ -0,0 +1,227 @@ +package io.bluewallet.bluewallet + +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.SharedPreferences +import android.util.Log +import com.bugsnag.android.Bugsnag +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactHost +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative +import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.drawee.backends.pipeline.Fresco +import com.facebook.react.modules.fresco.FrescoModule +import com.facebook.react.modules.i18nmanager.I18nUtil +import io.bluewallet.bluewallet.components.segmentedcontrol.SegmentedControlPackage + +class MainApplication : Application(), ReactApplication { + + private lateinit var sharedPref: SharedPreferences + private val themeChangeReceiver = ThemeChangeReceiver() + private val preferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { prefs, key -> + if (key == "preferredCurrency") { + prefs.edit().remove("previous_price").apply() + + // Update BitcoinPrice widgets + WidgetUpdateWorker.scheduleWork(this) + + // Immediately refresh Market widgets + MarketWidget.refreshAllWidgetsImmediately(this) + } else if (key == "force_dark_mode") { + // Theme setting changed, update all widgets + ThemeHelper.updateAllWidgets(this) + } else if (key == "donottrack") { + // Handle Do Not Track changes similar to iOS + val isEnabled = prefs.getString("donottrack", "0") == "1" + Log.d("MainApplication", "Do Not Track changed to: $isEnabled") + + if (isEnabled) { + // Set deviceUIDCopy to "Disabled" + prefs.edit() + .putString("deviceUIDCopy", "Disabled") + .apply() + Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'") + } else { + // Re-initialize device UID + initializeDeviceUID() + } + } else if (key == "deviceUID") { + // When deviceUID changes, update deviceUIDCopy + val isDoNotTrackEnabled = prefs.getString("donottrack", "0") == "1" + if (!isDoNotTrackEnabled) { + val deviceUID = prefs.getString("deviceUID", null) + if (deviceUID != null) { + prefs.edit() + .putString("deviceUIDCopy", deviceUID) + .apply() + Log.d("MainApplication", "deviceUID changed, synced to deviceUIDCopy: $deviceUID") + } + } + } + } + + override val reactNativeHost: ReactNativeHost by lazy { + object : DefaultReactNativeHost(this) { + override fun getPackages() = + PackageList(this).packages.apply { + // Packages that cannot be autolinked yet can be added manually here, for example: + // add(MyReactNativePackage()) + add(SegmentedControlPackage()) + add(SettingsPackage()) + } + + override fun getUseDeveloperSupport() = BuildConfig.DEBUG + + override fun getJSMainModuleName() = "index" + } + } + + override val reactHost: ReactHost by lazy { + getDefaultReactHost(applicationContext, reactNativeHost) + } + + override fun onCreate() { + super.onCreate() + sharedPref = getSharedPreferences("group.io.bluewallet.bluewallet", Context.MODE_PRIVATE) + + // Handle clearFilesOnLaunch before registering listeners + clearFilesIfNeeded() + + sharedPref.registerOnSharedPreferenceChangeListener(preferenceChangeListener) + + // Register the theme change receiver + registerReceiver(themeChangeReceiver, IntentFilter(Intent.ACTION_CONFIGURATION_CHANGED)) + + val sharedI18nUtilInstance = I18nUtil.getInstance() + sharedI18nUtilInstance.allowRTL(applicationContext, true) + + // Initialize Fresco before RN mounts views. FrescoModule init can lag behind the first + // frame (e.g. UnlockWith logo) when OkHttp/SSL warms up network security config. + if (!FrescoModule.hasBeenInitialized()) { + Fresco.initialize(this) + } + + loadReactNative(this) + + initializeDeviceUID() + initializeBugsnag() + } + + override fun onTerminate() { + super.onTerminate() + sharedPref.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener) + + // Unregister the theme change receiver + try { + unregisterReceiver(themeChangeReceiver) + } catch (e: Exception) { + Log.e("MainApplication", "Error unregistering theme receiver", e) + } + } + + private fun initializeBugsnag() { + val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0") + if (isDoNotTrackEnabled != "1") { + Bugsnag.start(this) + } + } + + /** + * Initialize device UID similar to iOS implementation + * Uses the same Android ID as react-native-device-info's getUniqueId() + */ + private fun initializeDeviceUID() { + val isDoNotTrackEnabled = sharedPref.getString("donottrack", "0") == "1" + + if (isDoNotTrackEnabled) { + val currentCopy = sharedPref.getString("deviceUIDCopy", "") + if (currentCopy != "Disabled") { + sharedPref.edit() + .putString("deviceUIDCopy", "Disabled") + .apply() + Log.d("MainApplication", "Do Not Track enabled - set deviceUIDCopy to 'Disabled'") + } + return + } + + // Get the Android ID (same as react-native-device-info's getUniqueId()) + val deviceUID = try { + android.provider.Settings.Secure.getString( + contentResolver, + android.provider.Settings.Secure.ANDROID_ID + ) ?: "unknown" + } catch (e: Exception) { + Log.e("MainApplication", "Error getting Android ID", e) + "unknown" + } + + // Store in deviceUID for consistency + sharedPref.edit() + .putString("deviceUID", deviceUID) + .apply() + + // Copy deviceUID to deviceUIDCopy (for Settings compatibility) + val currentCopy = sharedPref.getString("deviceUIDCopy", "") + if (deviceUID != currentCopy) { + sharedPref.edit() + .putString("deviceUIDCopy", deviceUID) + .apply() + Log.d("MainApplication", "Synced deviceUID to deviceUIDCopy: $deviceUID") + } + } + + /** + * Clear files if clearFilesOnLaunch is enabled + * Similar to iOS implementation + */ + private fun clearFilesIfNeeded() { + val shouldClear = sharedPref.getBoolean("clearFilesOnLaunch", false) + + if (shouldClear) { + try { + // Clear cache directory + cacheDir?.let { clearDirectory(it) } + + // Clear files directory + filesDir?.let { clearDirectory(it) } + + // Clear external cache directory + externalCacheDir?.let { clearDirectory(it) } + + // Reset the flag and set a flag to show alert + sharedPref.edit() + .putBoolean("clearFilesOnLaunch", false) + .putBoolean("shouldShowCacheClearedAlert", true) + .apply() + + Log.d("MainApplication", "Cache and files cleared on launch") + } catch (e: Exception) { + Log.e("MainApplication", "Error clearing files", e) + } + } + } + + /** + * Recursively clear all files in a directory + */ + private fun clearDirectory(dir: java.io.File) { + if (!dir.exists()) return + + dir.listFiles()?.forEach { file -> + if (file.isDirectory) { + clearDirectory(file) + } + try { + file.delete() + Log.d("MainApplication", "Deleted: ${file.absolutePath}") + } catch (e: Exception) { + Log.e("MainApplication", "Error deleting file: ${file.absolutePath}", e) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt new file mode 100644 index 00000000000..76aac60620f --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketAPI.kt @@ -0,0 +1,450 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.json.JSONArray +import org.json.JSONObject +import java.text.NumberFormat +import java.util.Currency +import kotlin.math.min + +object MarketAPI { + + private const val TAG = "MarketAPI" + private val client = OkHttpClient() + private val numberFormatter = NumberFormat.getNumberInstance() + private val electrumClient = ElectrumClient() + + private var lastFetchedFee: String? = null + + // Single indicator for error/unavailable + private const val ERROR_INDICATOR = "!" + + var baseUrl: String? = null + + data class ApiResponse(val body: String?, val code: Int) + data class PriceResult(val rateDouble: Double, val formattedRate: String?) + + suspend fun fetchPrice(context: Context, currency: String): String? { + Log.i(TAG, "Fetching Bitcoin price for currency: $currency") + val startTime = System.currentTimeMillis() + + return try { + val response = fetchPriceWithResponse(context, currency) + val duration = System.currentTimeMillis() - startTime + + if (response.code == 200) { + Log.i(TAG, "Successfully fetched price in ${duration}ms: ${response.body}") + response.body + } else { + Log.e(TAG, "Failed to fetch price in ${duration}ms, response code: ${response.code}") + null + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching price for $currency", e) + null + } + } + + suspend fun fetchPriceWithResponse(context: Context, currency: String): ApiResponse { + val startTime = System.currentTimeMillis() + Log.d(TAG, "Starting price fetch for currency: $currency") + + try { + // Load the currency info from JSON + val fiatUnitsJson = context.assets.open("fiatUnits.json").bufferedReader().use { it.readText() } + val json = JSONObject(fiatUnitsJson) + + if (!json.has(currency)) { + Log.e(TAG, "Currency $currency not found in fiatUnits.json") + return ApiResponse(null, 404) + } + + val currencyInfo = json.getJSONObject(currency) + val source = currencyInfo.getString("source") + val endPointKey = currencyInfo.getString("endPointKey") + + Log.d(TAG, "Using price source: $source, endpoint key: $endPointKey") + + val urlString = buildURLString(source, endPointKey) + Log.d(TAG, "Fetching price from URL: $urlString") + + val request = Request.Builder().url(urlString).build() + val apiStartTime = System.currentTimeMillis() + + val response = withContext(Dispatchers.IO) { client.newCall(request).execute() } + val apiDuration = System.currentTimeMillis() - apiStartTime + + val responseCode = response.code + Log.d(TAG, "Price API response received in ${apiDuration}ms, response code: $responseCode") + + if (responseCode == 429) { + Log.e(TAG, "Rate limited by API ($source). Response code: $responseCode, Headers: ${response.headers}") + return ApiResponse(null, responseCode) + } + + if (!response.isSuccessful) { + Log.e(TAG, "Failed to fetch price from $source. Response code: $responseCode") + return ApiResponse(null, responseCode) + } + + val jsonResponse = response.body?.string() + Log.d(TAG, "Raw response from $source: $jsonResponse") + + val parsedResult = if (jsonResponse != null) { + parseJSONBasedOnSource(jsonResponse, source, endPointKey) + } else null + + val totalDuration = System.currentTimeMillis() - startTime + if (parsedResult != null) { + Log.i(TAG, "Successfully parsed price for $currency from $source: $parsedResult (total time: ${totalDuration}ms)") + } else { + Log.e(TAG, "Failed to parse price for $currency from $source (total time: ${totalDuration}ms)") + } + + return ApiResponse(parsedResult, responseCode) + } catch (e: Exception) { + val totalDuration = System.currentTimeMillis() - startTime + Log.e(TAG, "Error fetching price for $currency after ${totalDuration}ms: ${e.javaClass.simpleName} - ${e.message}") + return ApiResponse(null, -1) + } + } + + private fun buildURLString(source: String, endPointKey: String): String { + return if (baseUrl != null) { + baseUrl + endPointKey + } else { + when (source) { + "Yadio" -> "https://api.yadio.io/json/$endPointKey" + "YadioConvert" -> "https://api.yadio.io/convert/1/BTC/$endPointKey" + "Exir" -> "https://api.exir.io/v1/ticker?symbol=btc-irt" + "coinpaprika" -> "https://api.coinpaprika.com/v1/tickers/btc-bitcoin?quotes=INR" + "Bitstamp" -> "https://www.bitstamp.net/api/v2/ticker/btc${endPointKey.lowercase()}" + "Coinbase" -> "https://api.coinbase.com/v2/prices/BTC-${endPointKey.uppercase()}/buy" + "CoinGecko" -> "https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${endPointKey.lowercase()}" + "BNR" -> "https://www.bnr.ro/nbrfxrates.xml" + "Kraken" -> "https://api.kraken.com/0/public/Ticker?pair=XXBTZ${endPointKey.uppercase()}" + "CoinDesk" -> "https://min-api.cryptocompare.com/data/price?fsym=BTC&tsyms=${endPointKey.uppercase()}" + else -> "https://min-api.cryptocompare.com/data/price?fsym=BTC&tsyms=${endPointKey.uppercase()}" + } + } + } + + private fun parseJSONBasedOnSource(jsonString: String, source: String, endPointKey: String): String? { + return try { + val json = JSONObject(jsonString) + when (source) { + "Yadio" -> json.getJSONObject(endPointKey).getString("price") + "YadioConvert" -> json.getString("rate") + "CoinGecko" -> json.getJSONObject("bitcoin").getString(endPointKey.lowercase()) + "Exir" -> json.getString("last") + "Bitstamp" -> json.getString("last") + "coinpaprika" -> json.getJSONObject("quotes").getJSONObject("INR").getString("price") + "Coinbase" -> json.getJSONObject("data").getString("amount") + "Kraken" -> json.getJSONObject("result").getJSONObject("XXBTZ${endPointKey.uppercase()}").getJSONArray("c").getString(0) + "CoinDesk" -> { + val rate = json.optDouble(endPointKey.uppercase(), -1.0) + if (rate < 0) null else rate.toString() + } + else -> null + } + } catch (e: Exception) { + Log.e(TAG, "Error parsing price", e) + null + } + } + + /** + * Fetch the next block fee from Electrum servers with network awareness + */ + suspend fun fetchNextBlockFee(context: Context): String { + val startTime = System.currentTimeMillis() + Log.i(TAG, "Fetching next block fee from Electrum") + + // Initialize ElectrumClient with context if not already done + electrumClient.initialize(context) + + // Set up network status listener + electrumClient.setNetworkStatusListener(object : ElectrumClient.NetworkStatusListener { + override fun onNetworkStatusChanged(isConnected: Boolean) { + Log.d(TAG, "Electrum network status changed: ${if (isConnected) "Connected" else "Disconnected"}") + } + + override fun onConnectionError(error: String) { + Log.e(TAG, "Electrum connection error: $error") + } + + override fun onConnectionSuccess() { + Log.d(TAG, "Successfully connected to Electrum server") + } + }) + + try { + // Check network connectivity first + if (!NetworkUtils.isNetworkAvailable(context)) { + Log.e(TAG, "No network connection available for fetching next block fee") + return ERROR_INDICATOR + } + + // For direct testing with hardcoded value + val useTestValue = false + if (useTestValue) { + Log.w(TAG, "Using TEST VALUE for next block fee") + return "25" + } + + // First try connecting directly for fee histogram + Log.d(TAG, "Attempting to connect directly to Electrum server for fee") + var success = electrumClient.connectToNextAvailable(validateCertificates = false) + + if (success) { + Log.i(TAG, "Connected to Electrum server: ${ElectrumClient.hardcodedPeers}") + } else { + Log.e(TAG, "Failed to connect to any Electrum server on first attempt. Retrying once more.") + } + + if (!success) { + Log.e(TAG, "Failed to connect to any Electrum server on first attempt. Retrying once more.") + // Short delay before retry + delay(1000) + success = electrumClient.connectToNextAvailable(validateCertificates = false) + + if (!success) { + Log.e(TAG, "Failed to connect to any Electrum server after retry. Fee unavailable.") + return ERROR_INDICATOR + } + } + + Log.d(TAG, "Successfully connected to Electrum server. Sending fee histogram request") + val message = "{\"id\": 1, \"method\": \"mempool.get_fee_histogram\", \"params\": []}\n" + if (!electrumClient.send(message.toByteArray())) { + Log.e(TAG, "Failed to send fee histogram request. Fee unavailable.") + return ERROR_INDICATOR + } + + Log.d(TAG, "Waiting for fee histogram response") + val receivedData = electrumClient.receive() + if (receivedData.isEmpty()) { + Log.e(TAG, "Empty response from Electrum server when requesting fee histogram. Fee unavailable.") + return ERROR_INDICATOR + } + + val jsonString = String(receivedData) + Log.d(TAG, "Received fee histogram: $jsonString") + + try { + val json = JSONObject(jsonString) + if (!json.has("result")) { + Log.e(TAG, "Invalid fee histogram response - missing 'result' field. Fee unavailable.") + return ERROR_INDICATOR + } + + val feeHistogram = json.getJSONArray("result") + if (feeHistogram.length() == 0) { + Log.e(TAG, "Empty fee histogram array. Fee unavailable.") + return ERROR_INDICATOR + } + + Log.d(TAG, "Calculating fee from ${feeHistogram.length()} data points") + + val feeRate = calculateFeeFromHistogram(feeHistogram, 1) + if (feeRate <= 0) { + Log.e(TAG, "Invalid fee rate calculated: $feeRate. Fee unavailable.") + return ERROR_INDICATOR + } + + val formattedFee = feeRate.toInt().toString() + + Log.i(TAG, "Successfully calculated next block fee: $formattedFee sat/vB") + return formattedFee + } catch (e: Exception) { + Log.e(TAG, "Error parsing fee histogram JSON: ${e.message}", e) + return ERROR_INDICATOR + } + } catch (e: Exception) { + Log.e(TAG, "Error fetching next block fee: ${e.message}", e) + return ERROR_INDICATOR + } finally { + electrumClient.close() + } + } + + /** + * Calculate the estimated fee from the fee histogram + * + * @param feeHistogram the fee histogram from Electrum + * @param targetBlocks the target number of blocks to confirm in + * @return the fee rate in sat/vB that would get confirmed in the target number of blocks + */ + private fun calculateFeeFromHistogram(feeHistogram: JSONArray, targetBlocks: Int): Double { + try { + Log.d(TAG, "Calculating fee from histogram with ${feeHistogram.length()} entries for $targetBlocks blocks") + + // Transform histogram - accumulate vsize until we reach the target block size + val blockSize = 1000000 // 1MB block size + var totalVsize = 0.0 + val histogramToUse = mutableListOf>() // (fee, vsize) + + for (i in 0 until feeHistogram.length()) { + val entry = feeHistogram.getJSONArray(i) + val feeRate = entry.getDouble(0) + var vsize = entry.getDouble(1) + var timeToStop = false + + if (totalVsize + vsize >= blockSize * targetBlocks) { + // Only take what we need to fill the target block size + vsize = blockSize * targetBlocks - totalVsize + timeToStop = true + } + + histogramToUse.add(Pair(feeRate, vsize)) + totalVsize += vsize + + Log.v(TAG, "Fee entry: rate=$feeRate, vsize=$vsize, accumulated=$totalVsize") + + if (timeToStop) break + } + + Log.d(TAG, "Transformed histogram has ${histogramToUse.size} entries with total vsize $totalVsize") + + // Create a weighted flat array (similar to the JS implementation) + val histogramFlat = mutableListOf() + for ((fee, vsize) in histogramToUse) { + // Divide by a factor to keep the array size manageable + val count = (vsize / 25000.0).toInt().coerceAtLeast(1) + repeat(count) { + histogramFlat.add(fee) + } + } + + if (histogramFlat.isEmpty()) { + Log.e(TAG, "Empty flat histogram array") + return 0.0 // Return 0 to indicate failure, will be caught and converted to ERROR_INDICATOR + } + + // Sort the flat array + histogramFlat.sort() + + // Calculate the median (50th percentile) + val median = calculatePercentile(histogramFlat, 0.5) + val result = median.coerceAtLeast(2.0) // Minimum 2 sat/vB + + Log.d(TAG, "Calculated median fee rate: $median, final rate: $result sat/vB") + return result + + } catch (e: Exception) { + Log.e(TAG, "Error calculating fee from histogram: ${e.message}", e) + return 0.0 // Return 0 to indicate failure, will be caught and converted to ERROR_INDICATOR + } + } + + /** + * Calculate the percentile of a sorted list of values + * + * @param sortedValues the sorted list of values + * @param percentile the percentile to calculate (0.0 - 1.0) + * @return the percentile value + */ + private fun calculatePercentile(sortedValues: List, percentile: Double): Double { + if (sortedValues.isEmpty()) return 0.0 + + val index = (percentile * sortedValues.size).toInt().coerceIn(0, sortedValues.size - 1) + return sortedValues[index] + } + + /** + * Format price with currency symbol + */ + fun formatCurrencyAmount(amount: Double, currencyCode: String): String { + val formatter = NumberFormat.getCurrencyInstance() + try { + formatter.currency = Currency.getInstance(currencyCode) + formatter.maximumFractionDigits = 0 // Ensure no fractional parts + } catch (e: Exception) { + Log.e(TAG, "Invalid currency code: $currencyCode", e) + } + return formatter.format(amount.toInt()) // Convert to integer before formatting + } + + /** + * Fetch complete market data including price and next block fee + */ + suspend fun fetchMarketData(context: Context, currency: String): MarketData { + val startTime = System.currentTimeMillis() + Log.i(TAG, "Starting market data fetch for currency: $currency") + + val marketData = MarketData(nextBlock = "...", sats = "...", price = "...", rate = 0.0) + + try { + // Check network connectivity first + if (!NetworkUtils.isNetworkAvailable(context)) { + Log.e(TAG, "No network connection available for fetching market data") + return marketData.apply { + nextBlock = ERROR_INDICATOR + sats = ERROR_INDICATOR + price = ERROR_INDICATOR + } + } + + // 1. Fetch price + Log.d(TAG, "Fetching price for $currency") + val priceStartTime = System.currentTimeMillis() + val response = fetchPriceWithResponse(context, currency) + val priceDuration = System.currentTimeMillis() - priceStartTime + + if (response.code == 429) { + Log.e(TAG, "Rate limited by price API, aborting market data fetch") + throw RateLimitException("Rate limited by price API") + } + + val priceStr = response.body + if (priceStr != null) { + val rate = priceStr.toDoubleOrNull() ?: 0.0 + marketData.rate = rate + Log.d(TAG, "Parsed price rate: $rate") + + if (rate > 0) { + // Format price with currency symbol - convert to integer + marketData.price = formatCurrencyAmount(rate, currency) + Log.d(TAG, "Formatted price: ${marketData.price}") + + // Calculate sats - convert to integer for display + val satsValue = ((10 / rate) * 10000000).toInt() + marketData.sats = numberFormatter.format(satsValue) + Log.d(TAG, "Calculated sats: ${marketData.sats}") + } else { + Log.w(TAG, "Price rate is zero or negative: $rate") + } + } else { + Log.w(TAG, "No price data received") + } + + // 2. Fetch next block fee - Always run this, regardless of price fetch result + Log.d(TAG, "Fetching next block fee") + val feeStartTime = System.currentTimeMillis() + val nextBlockFee = fetchNextBlockFee(context) + val feeDuration = System.currentTimeMillis() - feeStartTime + + Log.d(TAG, "Next block fee fetched in ${feeDuration}ms: $nextBlockFee") + marketData.nextBlock = nextBlockFee + Log.i(TAG, "Set nextBlock fee in marketData: ${marketData.nextBlock}") + + val totalDuration = System.currentTimeMillis() - startTime + Log.i(TAG, "Market data fetch completed in ${totalDuration}ms: $marketData") + + } catch (e: RateLimitException) { + Log.e(TAG, "Rate limit exception during market data fetch: ${e.message}") + throw e + } catch (e: Exception) { + val duration = System.currentTimeMillis() - startTime + Log.e(TAG, "Error fetching market data after ${duration}ms: ${e.javaClass.simpleName} - ${e.message}", e) + } + + return marketData + } +} \ No newline at end of file diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketData.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketData.kt new file mode 100644 index 00000000000..2755cb7b45c --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketData.kt @@ -0,0 +1,61 @@ +package io.bluewallet.bluewallet + +import android.util.Log +import java.text.NumberFormat +import java.util.Locale +import java.util.Date + +data class MarketData( + var nextBlock: String = "...", + var sats: String = "...", + var price: String = "...", + var rate: Double = 0.0, + var dateString: String = "" +) { + val formattedNextBlock: String + get() { + Log.d("MarketData", "Getting formatted next block from value: '$nextBlock'") + return when (nextBlock) { + "..." -> { + Log.d("MarketData", "Next block is a loading placeholder") + "..." + } + "!" -> { + Log.d("MarketData", "Next block is an error placeholder") + "!" + } + else -> { + try { + val nextBlockInt = nextBlock.toInt() + val numberFormatter = NumberFormat.getNumberInstance() + val formattedValue = "${numberFormatter.format(nextBlockInt)} sat/vb" + Log.d("MarketData", "Formatted next block: $formattedValue from $nextBlock") + formattedValue + } catch (e: Exception) { + Log.e("MarketData", "Error formatting next block value: '$nextBlock'", e) + "$nextBlock sat/vb" + } + } + } + } + + val formattedDate: String? + get() { + if (dateString.isEmpty()) return null + + try { + // Simple implementation - proper implementation would parse ISO8601 + return Date().toString() + } catch (e: Exception) { + return null + } + } + + companion object { + const val PREF_KEY = "market_data" + } + + override fun toString(): String { + return "MarketData(nextBlock=$nextBlock, sats=$sats, price=$price, rate=$rate, formattedNextBlock=$formattedNextBlock)" + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidget.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidget.kt new file mode 100644 index 00000000000..e0c7d168fb6 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidget.kt @@ -0,0 +1,229 @@ +package io.bluewallet.bluewallet + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.work.WorkManager +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.util.concurrent.TimeUnit +import io.bluewallet.bluewallet.ElectrumClient.ElectrumServer + +class MarketWidget : AppWidgetProvider() { + + companion object { + private const val TAG = "MarketWidget" + private const val SHARED_PREF_NAME = "group.io.bluewallet.bluewallet" + private const val DEFAULT_CURRENCY = "USD" + private const val KEY_LAST_ONLINE_STATUS = "market_widget_last_online_status" + + private val hardcodedPeers = listOf( + ElectrumServer("mainnet.foundationdevices.com", 50002, true), + ElectrumServer("electrum1.bluewallet.io", 443, true), + ElectrumServer("electrum.acinq.co", 50002, true), + ElectrumServer("electrum.bitaroo.net", 50002, true) + ) + + private suspend fun connectToElectrumServer(): Boolean { + for (peer in hardcodedPeers) { + repeat(3) { attempt -> + Log.d(TAG, "Attempting to connect to Electrum server: ${peer.host}:${peer.port}, Attempt: ${attempt + 1}") + val success = ElectrumClient().connect(peer, validateCertificates = true) + if (success) { + Log.i(TAG, "Successfully connected to Electrum server: ${peer.host}:${peer.port}") + return true + } else { + Log.w(TAG, "Failed to connect to Electrum server: ${peer.host}:${peer.port}, Attempt: ${attempt + 1}") + } + } + } + Log.e(TAG, "Failed to connect to any Electrum server from the hardcoded list after 3 attempts each. Waiting 10 minutes before retrying.") + delay(10 * 60 * 1000) // Wait for 10 minutes + return false + } + + fun updateWidget(context: Context, appWidgetId: Int) { + val appWidgetManager = AppWidgetManager.getInstance(context) + updateAppWidget(context, appWidgetManager, appWidgetId) + } + + fun updateAllWidgets(context: Context) { + val widgetIds = getAllWidgetIds(context) + if (widgetIds.isNotEmpty()) { + MarketWidgetUpdateWorker.scheduleMarketUpdate(context) + } + } + + fun refreshAllWidgetsImmediately(context: Context) { + val widgetIds = getAllWidgetIds(context) + if (widgetIds.isNotEmpty()) { + val appWidgetManager = AppWidgetManager.getInstance(context) + for (widgetId in widgetIds) { + updateAppWidget(context, appWidgetManager, widgetId) + } + + MarketWidgetUpdateWorker.scheduleMarketUpdate(context, forceUpdate = true) + + Log.d(TAG, "Scheduled immediate market widget update") + } + } + + fun getAllWidgetIds(context: Context): IntArray { + val appWidgetManager = AppWidgetManager.getInstance(context) + val thisWidget = ComponentName(context, MarketWidget::class.java) + return appWidgetManager.getAppWidgetIds(thisWidget) + } + + private fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int) { + Log.d(TAG, "Updating widget: $appWidgetId") + + // Check network connectivity + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(context) + + // Store connectivity status + storeConnectivityStatus(context, isNetworkAvailable) + + // Get market data from shared preferences + val marketData = getStoredMarketData(context) + Log.d(TAG, "Retrieved market data for widget: $marketData") + + // Create RemoteViews to update the widget + val views = RemoteViews(context.packageName, R.layout.widget_market) + + views.setViewVisibility(R.id.network_status, if (isNetworkAvailable) View.GONE else View.VISIBLE) + + // Add click intent to open the app + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_CLEAR_TOP or + Intent.FLAG_ACTIVITY_SINGLE_TOP + action = Intent.ACTION_MAIN + addCategory(Intent.CATEGORY_LAUNCHER) + } + + val pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + views.setOnClickPendingIntent(R.id.widget_market, pendingIntent) + + // Set the text for each view + val formattedNextBlock = marketData.formattedNextBlock + Log.d(TAG, "Setting next block value to: '$formattedNextBlock'") + + val displayText = when (formattedNextBlock) { + "..." -> context.getString(R.string.loading_placeholder, "...") + "!" -> context.getString(R.string.error_placeholder, "!") + else -> formattedNextBlock + } + views.setTextViewText(R.id.next_block_value, displayText) + + // Get the user preferred currency + val currency = getPreferredCurrency(context) + views.setTextViewText(R.id.sats_label, context.getString(R.string.market_sats_label, currency)) + views.setTextViewText(R.id.sats_value, marketData.sats) + views.setTextViewText(R.id.price_value, marketData.price) + + // Update the widget + appWidgetManager.updateAppWidget(appWidgetId, views) + + // Schedule update if network available, otherwise retry in 30 seconds + if (isNetworkAvailable) { + MarketWidgetUpdateWorker.scheduleMarketUpdate(context) + } else { + MarketWidgetUpdateWorker.scheduleRetryOnNetworkAvailable(context) + } + } + + + + private fun storeConnectivityStatus(context: Context, isOnline: Boolean) { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + sharedPrefs.edit().putBoolean(KEY_LAST_ONLINE_STATUS, isOnline).apply() + } + + private fun getStoredMarketData(context: Context): MarketData { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val marketDataJson = sharedPrefs.getString(MarketData.PREF_KEY, null) + + Log.d(TAG, "Reading market data from preferences: $marketDataJson") + + return if (marketDataJson != null) { + try { + val json = JSONObject(marketDataJson) + val nextBlock = json.optString("nextBlock", "...") + Log.d(TAG, "Retrieved nextBlock from storage: $nextBlock") + + MarketData( + nextBlock = nextBlock, + sats = json.optString("sats", "..."), + price = json.optString("price", "..."), + rate = json.optDouble("rate", 0.0), + dateString = json.optString("dateString", "") + ) + } catch (e: Exception) { + Log.e(TAG, "Error parsing stored market data", e) + MarketData() + } + } else { + Log.d(TAG, "No market data found in preferences") + MarketData() + } + } + + private fun getPreferredCurrency(context: Context): String { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val preferredCurrency = sharedPrefs.getString("preferredCurrency", null) + return preferredCurrency ?: DEFAULT_CURRENCY // Default to USD if no currency is saved + } + } + + override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) { + super.onUpdate(context, appWidgetManager, appWidgetIds) + Log.d(TAG, "MarketWidget onUpdate called. Widget IDs: ${appWidgetIds.joinToString()}") + + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + + MarketWidgetUpdateWorker.scheduleMarketUpdate(context) + } + + override fun onEnabled(context: Context) { + super.onEnabled(context) + Log.d(TAG, "MarketWidget enabled - First widget added") + val widgetIds = getAllWidgetIds(context) + if (widgetIds.isNotEmpty()) { + MarketWidgetUpdateWorker.scheduleMarketUpdate(context, forceUpdate = true) + } + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + Log.d(TAG, "MarketWidget disabled - Last widget removed") + // Cancel all scheduled work when last widget is removed + val workManager = WorkManager.getInstance(context) + workManager.cancelUniqueWork(MarketWidgetUpdateWorker.WORK_NAME) + workManager.cancelUniqueWork(MarketWidgetUpdateWorker.NETWORK_RETRY_WORK_NAME) + + // Clear cached data + clearMarketData(context) + } + + private fun clearMarketData(context: Context) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .remove(MarketData.PREF_KEY) + .remove(KEY_LAST_ONLINE_STATUS) + .apply() + Log.d(TAG, "Market widget data cleared") + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetConfigureActivity.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetConfigureActivity.kt new file mode 100644 index 00000000000..faa146a7490 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetConfigureActivity.kt @@ -0,0 +1,46 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.content.Intent +import android.os.Bundle +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity + +/** + * Configuration activity for the Market Widget. + * This allows the widget to be properly configured when added to the home screen. + */ +class MarketWidgetConfigureActivity : AppCompatActivity() { + + private var appWidgetId = AppWidgetManager.INVALID_APPWIDGET_ID + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // Set the result to CANCELED. This will be overridden if the user + // configures the widget properly and clicks the Add button + setResult(RESULT_CANCELED) + + // Find the widget id from the intent + appWidgetId = intent.extras?.getInt( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) ?: AppWidgetManager.INVALID_APPWIDGET_ID + + // If the widget ID is invalid, just finish the activity + if (appWidgetId == AppWidgetManager.INVALID_APPWIDGET_ID) { + finish() + return + } + + // Currently no configuration needed, so we set result to OK right away + val resultValue = Intent() + resultValue.putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + setResult(RESULT_OK, resultValue) + + MarketWidgetUpdateWorker.scheduleMarketUpdate(this, forceUpdate = true) + + // Finish the activity + finish() + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetUpdateWorker.kt b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetUpdateWorker.kt new file mode 100644 index 00000000000..236f727a833 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/MarketWidgetUpdateWorker.kt @@ -0,0 +1,226 @@ +package io.bluewallet.bluewallet + +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.util.Log +import androidx.work.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.util.concurrent.TimeUnit + +class MarketWidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + companion object { + const val TAG = "MarketWidgetUpdateWorker" + const val WORK_NAME = "market_widget_update_work" + const val NETWORK_RETRY_WORK_NAME = "market_network_retry_work" + private const val SHARED_PREF_NAME = "group.io.bluewallet.bluewallet" + private const val DEFAULT_CURRENCY = "USD" + private const val KEY_LAST_UPDATE_TIME = "market_widget_last_update_time" + private const val MIN_UPDATE_INTERVAL_MS = 15L * 60 * 1000 + private const val RATE_LIMIT_COOLDOWN_MS = 30L * 60 * 1000 + private const val NETWORK_RETRY_DELAY_SECONDS = 30L + + fun scheduleMarketUpdate(context: Context, forceUpdate: Boolean = false) { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val lastUpdateTime = sharedPrefs.getLong(KEY_LAST_UPDATE_TIME, 0) + val currentTime = System.currentTimeMillis() + + if (!forceUpdate && currentTime - lastUpdateTime < MIN_UPDATE_INTERVAL_MS) { + Log.d(TAG, "Skipping update - too soon since last update") + return + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val initialDelay = if (forceUpdate) 0 else calculateInitialDelay(context) + + val updateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(initialDelay, TimeUnit.MILLISECONDS) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.MINUTES) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + updateRequest + ) + + Log.d(TAG, "Scheduled market widget update work with delay: ${initialDelay}ms") + } + + /** + * Calculate delay for rate limiting + */ + private fun calculateInitialDelay(context: Context): Long { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val rateLimitedTime = sharedPrefs.getLong("market_widget_rate_limited_time", 0) + val currentTime = System.currentTimeMillis() + + return if (rateLimitedTime > 0 && currentTime - rateLimitedTime < RATE_LIMIT_COOLDOWN_MS) { + val remainingCooldown = RATE_LIMIT_COOLDOWN_MS - (currentTime - rateLimitedTime) + Log.d(TAG, "Rate limit cooldown active, delaying for ${remainingCooldown}ms") + remainingCooldown + } else { + 0 + } + } + + fun scheduleRetryOnNetworkAvailable(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val updateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(NETWORK_RETRY_DELAY_SECONDS, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + NETWORK_RETRY_WORK_NAME, + ExistingWorkPolicy.REPLACE, + updateRequest + ) + + Log.d(TAG, "Scheduled network retry in $NETWORK_RETRY_DELAY_SECONDS seconds") + } + } + + override suspend fun doWork(): Result { + Log.d(TAG, "MarketWidgetUpdateWorker running. Confirming interaction with MainActivity.") + return updateMarketWidgets() + } + + private suspend fun updateMarketWidgets(): Result { + Log.d(TAG, "Starting market widget update work") + val widgetIds = MarketWidget.getAllWidgetIds(applicationContext) + + val currency = getPreferredCurrency(applicationContext) + + try { + markUpdateTime() + + // Fetch market data + Log.i(TAG, "About to call MarketAPI.fetchMarketData") + val marketData = withContext(Dispatchers.IO) { + MarketAPI.fetchMarketData(applicationContext, currency) + } + Log.i(TAG, "Received market data from API: $marketData with nextBlock=${marketData.nextBlock}") + + storeMarketData(marketData) + Log.i(TAG, "Stored market data including nextBlock=${marketData.nextBlock}") + + for (widgetId in widgetIds) { + MarketWidget.updateWidget(applicationContext, widgetId) + } + + if (marketData.rate > 0) { + clearRateLimitFlag() + scheduleNextMarketUpdate(TimeUnit.MINUTES.toMillis(30)) + return Result.success() + } else { + Log.w(TAG, "Market data fetch returned invalid rate (${marketData.rate}), but fee may be available") + scheduleNextMarketUpdate(TimeUnit.MINUTES.toMillis(15)) + return Result.retry() + } + } catch (e: RateLimitException) { + Log.e(TAG, "Rate limit encountered", e) + setRateLimitFlag() + scheduleNextMarketUpdate(RATE_LIMIT_COOLDOWN_MS) + return Result.failure() + } catch (e: Exception) { + Log.e(TAG, "Error updating market widget", e) + scheduleNextMarketUpdate(TimeUnit.MINUTES.toMillis(15)) + return Result.retry() + } + } + + /** + * Store market data in shared preferences + */ + private fun storeMarketData(marketData: MarketData) { + try { + val json = JSONObject().apply { + put("nextBlock", marketData.nextBlock) + put("sats", marketData.sats) + put("price", marketData.price) + put("rate", marketData.rate) + put("dateString", marketData.dateString) + } + + val jsonString = json.toString() + Log.d(TAG, "Storing market data JSON: $jsonString") + + applicationContext.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putString(MarketData.PREF_KEY, jsonString) + .apply() + + Log.d(TAG, "Stored market data: $marketData") + } catch (e: Exception) { + Log.e(TAG, "Error storing market data", e) + } + } + + private fun scheduleNextMarketUpdate(delayMs: Long) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val updateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(delayMs, TimeUnit.MILLISECONDS) + .build() + + WorkManager.getInstance(applicationContext).enqueueUniqueWork( + WORK_NAME, + ExistingWorkPolicy.REPLACE, + updateRequest + ) + + Log.d(TAG, "Scheduled next market update with delay: ${delayMs}ms") + } + + /** + * Get user's preferred currency + */ + private fun getPreferredCurrency(context: Context): String { + val sharedPrefs = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + return sharedPrefs.getString("preferredCurrency", DEFAULT_CURRENCY) ?: DEFAULT_CURRENCY + } + + /** + * Mark last update time + */ + private fun markUpdateTime() { + applicationContext.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putLong(KEY_LAST_UPDATE_TIME, System.currentTimeMillis()) + .apply() + } + + /** + * Set rate limit flag when API rate limit encountered + */ + private fun setRateLimitFlag() { + applicationContext.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putLong("market_widget_rate_limited_time", System.currentTimeMillis()) + .apply() + } + + /** + * Clear rate limit flag + */ + private fun clearRateLimitFlag() { + applicationContext.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .remove("market_widget_rate_limited_time") + .apply() + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/NetworkUtils.kt b/android/app/src/main/java/io/bluewallet/bluewallet/NetworkUtils.kt new file mode 100644 index 00000000000..45d1252f755 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/NetworkUtils.kt @@ -0,0 +1,32 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build + +object NetworkUtils { + /** + * Check if the device has an active network connection + * @param context Application context + * @return true if connected, false otherwise + */ + fun isNetworkAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + + when { + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true + else -> false + } + } else { + @Suppress("DEPRECATION") + connectivityManager.activeNetworkInfo?.isConnected ?: false + } + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/RateLimitException.kt b/android/app/src/main/java/io/bluewallet/bluewallet/RateLimitException.kt new file mode 100644 index 00000000000..81d59344b45 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/RateLimitException.kt @@ -0,0 +1,6 @@ +package io.bluewallet.bluewallet + +/** + * Exception thrown when an API rate limit is encountered + */ +class RateLimitException(message: String) : Exception(message) diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/SettingsActivity.kt b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsActivity.kt new file mode 100644 index 00000000000..5eb40ce9ff1 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsActivity.kt @@ -0,0 +1,86 @@ +package io.bluewallet.bluewallet + +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AppCompatActivity +import androidx.preference.PreferenceFragmentCompat + +/** + * Settings Activity accessible from Android System Settings + */ +class SettingsActivity : AppCompatActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.settings_activity) + + Log.d("SettingsActivity", "Settings activity created") + + // Enable back button in action bar + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + if (savedInstanceState == null) { + supportFragmentManager + .beginTransaction() + .replace(R.id.settings_container, SettingsFragment()) + .commit() + } + } + + override fun onSupportNavigateUp(): Boolean { + finish() + return true + } + + class SettingsFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + // Set the SharedPreferences name to match the app's preferences + preferenceManager.sharedPreferencesName = "group.io.bluewallet.bluewallet" + + // Load preferences from XML + setPreferencesFromResource(R.xml.settings_preferences, rootKey) + + Log.d("SettingsFragment", "Preferences loaded from XML") + + // Set up click listener for deviceUIDCopy to copy to clipboard + val deviceUIDPref = findPreference("deviceUIDCopy") + deviceUIDPref?.let { pref -> + // Get the device UID from SharedPreferences + val sharedPref = preferenceManager.sharedPreferences + val deviceUID = sharedPref?.getString("deviceUIDCopy", "") ?: "" + + // Set the summary to show the UUID + pref.summary = deviceUID + + // Check if report issue is disabled + val isDisabled = deviceUID == "Disabled" + + // Make it non-selectable if disabled + pref.isSelectable = !isDisabled + + // Set click listener to copy to clipboard (only if not disabled) + if (!isDisabled) { + pref.setOnPreferenceClickListener { + if (deviceUID.isNotEmpty()) { + val clipboard = requireContext().getSystemService(android.content.Context.CLIPBOARD_SERVICE) + as android.content.ClipboardManager + val clip = android.content.ClipData.newPlainText("Device UID", deviceUID) + clipboard.setPrimaryClip(clip) + + // Show a toast message + android.widget.Toast.makeText( + requireContext(), + R.string.copied_to_clipboard, + android.widget.Toast.LENGTH_SHORT + ).show() + + Log.d("SettingsFragment", "Device UID copied to clipboard: $deviceUID") + } + true + } + } + } + } + } +} + diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/SettingsModule.kt b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsModule.kt new file mode 100644 index 00000000000..4c32ef6e06b --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsModule.kt @@ -0,0 +1,211 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.content.SharedPreferences +import android.util.Log +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.Promise +import com.facebook.react.module.annotations.ReactModule +import io.bluewallet.bluewallet.NativeSettingsModuleSpec + +@ReactModule(name = SettingsModule.NAME) +class SettingsModule(reactContext: ReactApplicationContext) : NativeSettingsModuleSpec(reactContext) { + + private val sharedPref: SharedPreferences = reactContext.getSharedPreferences( + "group.io.bluewallet.bluewallet", + Context.MODE_PRIVATE + ) + + companion object { + private const val TAG = "SettingsModule" + private const val DEVICE_UID_KEY = "deviceUID" + private const val DEVICE_UID_COPY_KEY = "deviceUIDCopy" + private const val CLEAR_FILES_ON_LAUNCH_KEY = "clearFilesOnLaunch" + private const val DO_NOT_TRACK_KEY = "donottrack" + const val NAME = "SettingsModule" + } + + /** + * Initialize device UID if not exists + * Uses the same Android ID as react-native-device-info's getUniqueId() + */ + @ReactMethod + override fun initializeDeviceUID(promise: Promise) { + try { + val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1" + + if (isDoNotTrackEnabled) { + // Set deviceUIDCopy to "Disabled" if Do Not Track is enabled + val currentCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "") + if (currentCopy != "Disabled") { + sharedPref.edit() + .putString(DEVICE_UID_COPY_KEY, "Disabled") + .apply() + Log.d(TAG, "Do Not Track enabled - set deviceUIDCopy to 'Disabled'") + } + promise.resolve("Disabled") + return + } + + // Get the Android ID (same as react-native-device-info's getUniqueId()) + val deviceUID = try { + android.provider.Settings.Secure.getString( + reactApplicationContext.contentResolver, + android.provider.Settings.Secure.ANDROID_ID + ) ?: "unknown" + } catch (e: Exception) { + Log.e(TAG, "Error getting Android ID", e) + "unknown" + } + + // Store in deviceUID for consistency + sharedPref.edit() + .putString(DEVICE_UID_KEY, deviceUID) + .apply() + + // Copy deviceUID to deviceUIDCopy (for Settings.bundle compatibility) + val currentCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "") + if (deviceUID != currentCopy) { + sharedPref.edit() + .putString(DEVICE_UID_COPY_KEY, deviceUID) + .apply() + Log.d(TAG, "Synced deviceUID to deviceUIDCopy: $deviceUID") + } + + promise.resolve(deviceUID) + } catch (e: Exception) { + Log.e(TAG, "Error initializing deviceUID", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Get the device UID + */ + @ReactMethod + override fun getDeviceUID(promise: Promise) { + try { + val isDoNotTrackEnabled = sharedPref.getString(DO_NOT_TRACK_KEY, "0") == "1" + + if (isDoNotTrackEnabled) { + promise.resolve("Disabled") + return + } + + val deviceUID = sharedPref.getString(DEVICE_UID_KEY, null) + promise.resolve(deviceUID) + } catch (e: Exception) { + Log.e(TAG, "Error getting deviceUID", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Get the device UID copy (for Settings display) + */ + @ReactMethod + override fun getDeviceUIDCopy(promise: Promise) { + try { + val deviceUIDCopy = sharedPref.getString(DEVICE_UID_COPY_KEY, "") + promise.resolve(deviceUIDCopy) + } catch (e: Exception) { + Log.e(TAG, "Error getting deviceUIDCopy", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Set the clearFilesOnLaunch preference + */ + @ReactMethod + override fun setClearFilesOnLaunch(value: Boolean, promise: Promise) { + try { + sharedPref.edit() + .putBoolean(CLEAR_FILES_ON_LAUNCH_KEY, value) + .apply() + Log.d(TAG, "Set clearFilesOnLaunch to: $value") + promise.resolve(value) + } catch (e: Exception) { + Log.e(TAG, "Error setting clearFilesOnLaunch", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Get the clearFilesOnLaunch preference + */ + @ReactMethod + override fun getClearFilesOnLaunch(promise: Promise) { + try { + val value = sharedPref.getBoolean(CLEAR_FILES_ON_LAUNCH_KEY, false) + promise.resolve(value) + } catch (e: Exception) { + Log.e(TAG, "Error getting clearFilesOnLaunch", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Set Do Not Track setting + */ + @ReactMethod + override fun setDoNotTrack(enabled: Boolean, promise: Promise) { + try { + val value = if (enabled) "1" else "0" + sharedPref.edit() + .putString(DO_NOT_TRACK_KEY, value) + .apply() + + Log.d(TAG, "Set donottrack to: $value") + + // Update deviceUIDCopy based on Do Not Track setting + if (enabled) { + sharedPref.edit() + .putString(DEVICE_UID_COPY_KEY, "Disabled") + .apply() + Log.d(TAG, "Do Not Track enabled - set deviceUIDCopy to 'Disabled'") + } else { + // Re-initialize device UID + initializeDeviceUID(promise) + return + } + + promise.resolve(enabled) + } catch (e: Exception) { + Log.e(TAG, "Error setting donottrack", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Get Do Not Track setting + */ + @ReactMethod + override fun getDoNotTrack(promise: Promise) { + try { + val value = sharedPref.getString(DO_NOT_TRACK_KEY, "0") + val enabled = value == "1" + promise.resolve(enabled) + } catch (e: Exception) { + Log.e(TAG, "Error getting donottrack", e) + promise.reject("ERROR", e.message) + } + } + + /** + * Open the settings activity from JavaScript + */ + @ReactMethod + override fun openSettings(promise: Promise) { + try { + val intent = android.content.Intent(reactApplicationContext, SettingsActivity::class.java) + intent.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + reactApplicationContext.startActivity(intent) + promise.resolve(true) + } catch (e: Exception) { + Log.e(TAG, "Error opening settings", e) + promise.reject("ERROR", e.message) + } + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/SettingsPackage.kt b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsPackage.kt new file mode 100644 index 00000000000..deed9545232 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/SettingsPackage.kt @@ -0,0 +1,29 @@ +package io.bluewallet.bluewallet + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class SettingsPackage : TurboReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == SettingsModule.NAME) SettingsModule(reactContext) else null + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = ReactModuleInfoProvider { + val moduleInfo = ReactModuleInfo( + SettingsModule.NAME, + SettingsModule.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + ) + mapOf(SettingsModule.NAME to moduleInfo) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = emptyList() +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/ThemeChangeReceiver.kt b/android/app/src/main/java/io/bluewallet/bluewallet/ThemeChangeReceiver.kt new file mode 100644 index 00000000000..681f178a719 --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/ThemeChangeReceiver.kt @@ -0,0 +1,27 @@ +package io.bluewallet.bluewallet + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.res.Configuration +import android.util.Log + +/** + * BroadcastReceiver to handle system theme changes (light/dark mode) + */ +class ThemeChangeReceiver : BroadcastReceiver() { + companion object { + private const val TAG = "ThemeChangeReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_CONFIGURATION_CHANGED) { + val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + + if (!ThemeHelper.isForceDarkModeEnabled(context)) { + Log.d(TAG, "Configuration changed, updating widgets for theme change") + AppWidgetUtils.updateWidgetsForThemeChange(context) + } + } + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/ThemeHelper.kt b/android/app/src/main/java/io/bluewallet/bluewallet/ThemeHelper.kt new file mode 100644 index 00000000000..2872bb0215d --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/ThemeHelper.kt @@ -0,0 +1,76 @@ +package io.bluewallet.bluewallet + +import android.content.Context +import android.content.res.Configuration +import androidx.appcompat.app.AppCompatDelegate + +object ThemeHelper { + private const val SHARED_PREF_NAME = "group.io.bluewallet.bluewallet" + private const val KEY_FORCE_DARK_MODE = "force_dark_mode" + + /** + * Check if dark mode is currently active + * @param context Application context + * @return true if dark mode is active, false otherwise + */ + fun isDarkModeActive(context: Context): Boolean { + val preferences = context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + val forceDarkMode = preferences.getBoolean(KEY_FORCE_DARK_MODE, false) + + return if (forceDarkMode) { + true + } else { + val currentNightMode = context.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK + currentNightMode == Configuration.UI_MODE_NIGHT_YES + } + } + + /** + * Set the force dark mode option + * @param context Application context + * @param forceDarkMode Whether to force dark mode + */ + fun setForceDarkMode(context: Context, forceDarkMode: Boolean) { + context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_FORCE_DARK_MODE, forceDarkMode) + .apply() + + // Apply theme setting immediately + AppCompatDelegate.setDefaultNightMode( + if (forceDarkMode) AppCompatDelegate.MODE_NIGHT_YES + else AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM + ) + + // Update widgets with new theme + updateAllWidgets(context) + } + + /** + * Get whether force dark mode is enabled + * @param context Application context + * @return true if force dark mode is enabled, false otherwise + */ + fun isForceDarkModeEnabled(context: Context): Boolean { + return context.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + .getBoolean(KEY_FORCE_DARK_MODE, false) + } + + /** + * Update all widgets to reflect current theme + * @param context Application context + */ + fun updateAllWidgets(context: Context) { + // Update Bitcoin Price Widgets + val bitcoinPriceWidgetIds = AppWidgetUtils.getBitcoinPriceWidgetIds(context) + if (bitcoinPriceWidgetIds.isNotEmpty()) { + BitcoinPriceWidget.updateNetworkStatus(context, bitcoinPriceWidgetIds) + } + + // Update Market Widgets + val marketWidgetIds = MarketWidget.getAllWidgetIds(context) + if (marketWidgetIds.isNotEmpty()) { + MarketWidget.refreshAllWidgetsImmediately(context) + } + } +} diff --git a/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt new file mode 100644 index 00000000000..14e5e2f8f3d --- /dev/null +++ b/android/app/src/main/java/io/bluewallet/bluewallet/WidgetUpdateWorker.kt @@ -0,0 +1,294 @@ +package io.bluewallet.bluewallet + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.SharedPreferences +import android.util.Log +import android.view.View +import android.widget.RemoteViews +import androidx.work.* +import java.text.DecimalFormatSymbols +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +class WidgetUpdateWorker(context: Context, workerParams: WorkerParameters) : CoroutineWorker(context, workerParams) { + + companion object { + const val TAG = "WidgetUpdateWorker" + const val WORK_NAME = "bitcoin_price_widget_update_work" + const val NETWORK_RETRY_WORK_NAME = "bitcoin_price_network_retry_work" + const val REPEAT_INTERVAL_MINUTES = 15L + private const val SHARED_PREF_NAME = "group.io.bluewallet.bluewallet" + private const val DEFAULT_CURRENCY = "USD" + private const val NETWORK_RETRY_DELAY_SECONDS = 30L + + fun scheduleWork(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(false) + .build() + + val workRequest = PeriodicWorkRequestBuilder( + REPEAT_INTERVAL_MINUTES, TimeUnit.MINUTES + ) + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) + .addTag(TAG) + .build() + + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + workRequest + ) + } + + fun scheduleImmediateUpdate(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val updateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) + .addTag(TAG) + .build() + + WorkManager.getInstance(context).enqueue(updateRequest) + } + + fun scheduleRetryOnNetworkAvailable(context: Context) { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + + val updateRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInitialDelay(NETWORK_RETRY_DELAY_SECONDS, TimeUnit.SECONDS) + .build() + + WorkManager.getInstance(context).enqueueUniqueWork( + NETWORK_RETRY_WORK_NAME, + ExistingWorkPolicy.REPLACE, + updateRequest + ) + } + } + + private lateinit var sharedPref: SharedPreferences + + override suspend fun doWork(): Result { + sharedPref = applicationContext.getSharedPreferences(SHARED_PREF_NAME, Context.MODE_PRIVATE) + + if (!NetworkUtils.isNetworkAvailable(applicationContext)) { + val component = ComponentName(applicationContext, BitcoinPriceWidget::class.java) + val widgetIds = AppWidgetManager.getInstance(applicationContext).getAppWidgetIds(component) + BitcoinPriceWidget.updateNetworkStatus(applicationContext, widgetIds) + scheduleRetryOnNetworkAvailable(applicationContext) + return Result.retry() + } + + return updatePriceWidgets() + } + + private suspend fun updatePriceWidgets(): Result { + val appWidgetManager = AppWidgetManager.getInstance(applicationContext) + val thisWidget = ComponentName(applicationContext, BitcoinPriceWidget::class.java) + val appWidgetIds = appWidgetManager.getAppWidgetIds(thisWidget) + val views = RemoteViews(applicationContext.packageName, R.layout.widget_layout) + + val intent = Intent(applicationContext, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP + action = "android.intent.action.MAIN" + addCategory("android.intent.category.LAUNCHER") + } + val pendingIntent = PendingIntent.getActivity( + applicationContext, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + views.setOnClickPendingIntent(R.id.widget_layout, pendingIntent) + + views.setViewVisibility(R.id.loading_indicator, View.VISIBLE) + views.setViewVisibility(R.id.price_value, View.GONE) + views.setViewVisibility(R.id.last_updated_label, View.GONE) + views.setViewVisibility(R.id.last_updated_time, View.GONE) + views.setViewVisibility(R.id.price_arrow_container, View.GONE) + + appWidgetManager.updateAppWidget(appWidgetIds, views) + + val preferredCurrency = sharedPref.getString("preferredCurrency", null) ?: "USD" + val preferredCurrencyLocale = sharedPref.getString("preferredCurrencyLocale", null) ?: "en-US" + val previousPrice = sharedPref.getString("previous_price", null) + + val currentTime = SimpleDateFormat("hh:mm a", Locale.getDefault()).format(Date()) + + val fetchedPrice = fetchPrice(preferredCurrency) + + // Check network connectivity + val isNetworkAvailable = NetworkUtils.isNetworkAvailable(applicationContext) + views.setViewVisibility(R.id.network_status, if (isNetworkAvailable) View.GONE else View.VISIBLE) + + handlePriceResult( + appWidgetManager, appWidgetIds, views, sharedPref, + fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale + ) + + return Result.success() + } + + private suspend fun fetchPrice(currency: String?): String? { + return withContext(Dispatchers.IO) { + MarketAPI.fetchPrice(applicationContext, currency ?: "USD") + } + } + + private fun handlePriceResult( + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray, + views: RemoteViews, + sharedPref: SharedPreferences, + fetchedPrice: String?, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String? + ) { + val isPriceFetched = fetchedPrice != null + val isPriceCached = previousPrice != null + + if (!isPriceFetched) { + Log.e(TAG, "Error fetching price.") + if (!isPriceCached) { + showLoadingError(views) + } else { + displayCachedPrice(views, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale) + } + } else { + if (fetchedPrice != null) { + displayFetchedPrice( + views, fetchedPrice, previousPrice, currentTime, preferredCurrency, preferredCurrencyLocale + ) + } + if (fetchedPrice != null) { + savePrice(sharedPref, fetchedPrice) + } + } + + appWidgetManager.updateAppWidget(appWidgetIds, views) + } + + private fun showLoadingError(views: RemoteViews) { + views.apply { + setViewVisibility(R.id.loading_indicator, View.GONE) + setViewVisibility(R.id.price_value, View.GONE) + setViewVisibility(R.id.last_updated_label, View.GONE) + setViewVisibility(R.id.last_updated_time, View.GONE) + setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } + + private fun displayCachedPrice( + views: RemoteViews, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String? + ) { + val parsedPrevious = previousPrice?.toDoubleOrNull() + val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale) + + views.apply { + setViewVisibility(R.id.loading_indicator, View.GONE) + setViewVisibility(R.id.price_arrow_container, View.GONE) + if (parsedPrevious != null) { + setTextViewText(R.id.price_value, currencyFormat.format(parsedPrevious.toInt())) + setViewVisibility(R.id.price_value, View.VISIBLE) + setViewVisibility(R.id.last_updated_label, View.VISIBLE) + setViewVisibility(R.id.last_updated_time, View.VISIBLE) + } else { + setViewVisibility(R.id.price_value, View.GONE) + setViewVisibility(R.id.last_updated_label, View.GONE) + setViewVisibility(R.id.last_updated_time, View.GONE) + } + setTextViewText(R.id.last_updated_time, currentTime) + } + } + + private fun displayFetchedPrice( + views: RemoteViews, + fetchedPrice: String, + previousPrice: String?, + currentTime: String, + preferredCurrency: String?, + preferredCurrencyLocale: String? + ) { + val currentPrice = fetchedPrice.toDoubleOrNull()?.toInt() + val currencyFormat = getCurrencyFormat(preferredCurrency, preferredCurrencyLocale) + + views.apply { + setViewVisibility(R.id.loading_indicator, View.GONE) + if (currentPrice != null) { + setTextViewText(R.id.price_value, currencyFormat.format(currentPrice)) + setTextViewText(R.id.last_updated_time, currentTime) + setViewVisibility(R.id.price_value, View.VISIBLE) + setViewVisibility(R.id.last_updated_label, View.VISIBLE) + setViewVisibility(R.id.last_updated_time, View.VISIBLE) + + val previousParsed = previousPrice?.toDoubleOrNull()?.toInt() + if (previousParsed != null) { + setViewVisibility(R.id.price_arrow_container, View.VISIBLE) + setTextViewText(R.id.previous_price, currencyFormat.format(previousParsed)) + setImageViewResource( + R.id.price_arrow, + if (currentPrice > previousParsed) android.R.drawable.arrow_up_float else android.R.drawable.arrow_down_float + ) + } else { + setViewVisibility(R.id.price_arrow_container, View.GONE) + } + } else { + // Fallback to loading state if parsing failed + setViewVisibility(R.id.price_value, View.GONE) + setViewVisibility(R.id.last_updated_label, View.GONE) + setViewVisibility(R.id.last_updated_time, View.GONE) + setViewVisibility(R.id.price_arrow_container, View.GONE) + setViewVisibility(R.id.loading_indicator, View.VISIBLE) + } + } + } + + private fun getCurrencyFormat(currencyCode: String?, localeString: String?): NumberFormat { + val locale = localeString + ?.let { runCatching { Locale.forLanguageTag(it) }.getOrNull() } + ?.takeIf { it.language.isNotBlank() } + ?: Locale.getDefault() + val currencyFormat = NumberFormat.getCurrencyInstance(locale) + val currency = try { + Currency.getInstance(currencyCode ?: "USD") + } catch (e: IllegalArgumentException) { + Currency.getInstance("USD") + } + currencyFormat.currency = currency + currencyFormat.maximumFractionDigits = 0 + + val decimalFormatSymbols = (currencyFormat as java.text.DecimalFormat).decimalFormatSymbols + decimalFormatSymbols.currencySymbol = currency.symbol + currencyFormat.decimalFormatSymbols = decimalFormatSymbols + + return currencyFormat + } + + private fun savePrice(sharedPref: SharedPreferences, price: String) { + sharedPref.edit().putString("previous_price", price).apply() + } +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable-mdpi/splash_icon.png b/android/app/src/main/res/drawable-mdpi/splash_icon.png new file mode 100644 index 00000000000..ec541528a9a Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splash_icon.png b/android/app/src/main/res/drawable-xhdpi/splash_icon.png new file mode 100644 index 00000000000..a72c80addc6 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splash_icon.png b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png new file mode 100644 index 00000000000..5727bf33172 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splash_icon.png differ diff --git a/android/app/src/main/res/drawable/green_pill_background.xml b/android/app/src/main/res/drawable/green_pill_background.xml new file mode 100644 index 00000000000..063ce8ad6a9 --- /dev/null +++ b/android/app/src/main/res/drawable/green_pill_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/market_widget_preview.png b/android/app/src/main/res/drawable/market_widget_preview.png new file mode 100644 index 00000000000..9ea3b22d19d Binary files /dev/null and b/android/app/src/main/res/drawable/market_widget_preview.png differ diff --git a/android/app/src/main/res/drawable/notification_icon.png b/android/app/src/main/res/drawable/notification_icon.png new file mode 100644 index 00000000000..02787243b63 Binary files /dev/null and b/android/app/src/main/res/drawable/notification_icon.png differ diff --git a/android/app/src/main/res/drawable/pill_shape_green.xml b/android/app/src/main/res/drawable/pill_shape_green.xml new file mode 100644 index 00000000000..842358e8aed --- /dev/null +++ b/android/app/src/main/res/drawable/pill_shape_green.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/pill_shape_red.xml b/android/app/src/main/res/drawable/pill_shape_red.xml new file mode 100644 index 00000000000..b6fac1db1bd --- /dev/null +++ b/android/app/src/main/res/drawable/pill_shape_red.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android/app/src/main/res/drawable/red_pill_background.xml b/android/app/src/main/res/drawable/red_pill_background.xml new file mode 100644 index 00000000000..590814d5e5e --- /dev/null +++ b/android/app/src/main/res/drawable/red_pill_background.xml @@ -0,0 +1,9 @@ + + + + + + diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml index f35d9962026..5c0239d0ad8 100644 --- a/android/app/src/main/res/drawable/rn_edit_text_material.xml +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -17,7 +17,8 @@ android:insetLeft="@dimen/abc_edit_text_inset_horizontal_material" android:insetRight="@dimen/abc_edit_text_inset_horizontal_material" android:insetTop="@dimen/abc_edit_text_inset_top_material" - android:insetBottom="@dimen/abc_edit_text_inset_bottom_material"> + android:insetBottom="@dimen/abc_edit_text_inset_bottom_material" + > + Bitcoin Market + View the latest Bitcoin market data including price, satoshi rate, and next block fee. + Market + Next Block + Sats/%s + Price + + + Bitcoin Price + View the latest Bitcoin price in your preferred currency + Offline + From: + Price + + %1$s + %1$s + + + Settings + Report Issue + Provide this Unique ID when reporting an issue + Unique ID + Cache + Clear Cache on Next Launch + Enable to clear all cached files when the app starts next time + Cache Cleared + The document, cache, and temp directories have been cleared. + Copied to clipboard diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index 7ba83a2ad5a..aa89f8253bd 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -1,9 +1,20 @@ - - - - + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/bitcoin_price_widget_info.xml b/android/app/src/main/res/xml/bitcoin_price_widget_info.xml new file mode 100644 index 00000000000..8d61bfee55f --- /dev/null +++ b/android/app/src/main/res/xml/bitcoin_price_widget_info.xml @@ -0,0 +1,18 @@ + + diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000000..ecb9eeccd2c --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/market_widget_info.xml b/android/app/src/main/res/xml/market_widget_info.xml new file mode 100644 index 00000000000..6657b69db53 --- /dev/null +++ b/android/app/src/main/res/xml/market_widget_info.xml @@ -0,0 +1,18 @@ + + diff --git a/android/app/src/main/res/xml/settings_preferences.xml b/android/app/src/main/res/xml/settings_preferences.xml new file mode 100644 index 00000000000..821424f4899 --- /dev/null +++ b/android/app/src/main/res/xml/settings_preferences.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle b/android/build.gradle index ecd03e872d7..e7a79a4c59f 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,33 +2,54 @@ buildscript { ext { - minSdkVersion = 28 - supportLibVersion = "28.0.0" - buildToolsVersion = "33.0.0" - compileSdkVersion = 33 - targetSdkVersion = 33 - googlePlayServicesVersion = "16.+" + minSdkVersion = 24 + + buildToolsVersion = "36.0.0" + compileSdkVersion = 36 + targetSdkVersion = 36 + googlePlayServicesVersion = "16.1.0" googlePlayServicesIidVersion = "16.0.1" - firebaseVersion = "17.3.4" - firebaseMessagingVersion = "21.1.0" - - // We use NDK 23 which has both M1 support and is the side-by-side NDK version from AGP. - ndkVersion = "23.1.7779620" - kotlin_version = '1.9.0' - kotlinVersion = '1.8.0' - + firebaseVersion = "21.1.0" + ndkVersion = "28.2.13676358" + kotlinVersion = '2.1.20' } repositories { google() mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:7.4.2") - classpath("com.bugsnag:bugsnag-android-gradle-plugin:5.+") - classpath 'com.google.gms:google-services:4.3.14' // Google Services plugin - classpath("com.facebook.react:react-native-gradle-plugin") - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + classpath("com.android.tools.build:gradle:8.13.2") + classpath("com.facebook.react:react-native-gradle-plugin") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin") + classpath 'com.google.gms:google-services:4.5.0' // Google Services plugin + classpath("com.bugsnag:bugsnag-android-gradle-plugin:8.2.0") + } +} +// Gradle 9 removes jcenter(); add a shim that redirects any jcenter() call to mavenCentral() +def addJcenterShim = { repoContainer, logger -> + def mc = repoContainer.metaClass + if (!mc.respondsTo(repoContainer, 'jcenter')) { + mc.jcenter << { Closure config = null -> + def repo = repoContainer.mavenCentral() + if (config != null) { + config.delegate = repo + config.resolveStrategy = Closure.DELEGATE_FIRST + config.call(repo) + } + logger.lifecycle("Redirected jcenter() to mavenCentral()") + return repo + } + } + if (!mc.respondsTo(repoContainer, 'methodMissing')) { + mc.methodMissing = { String name, args -> + if (name == 'jcenter') { + def repo = repoContainer.mavenCentral() + logger.lifecycle("Redirected jcenter() (methodMissing) to mavenCentral()") + return repo + } + throw new MissingMethodException(name, repoContainer.class, args) + } } } @@ -37,23 +58,14 @@ allprojects { maven { url("$rootDir/../node_modules/detox/Detox-android") } - jcenter() { - content { - includeModule("com.facebook.yoga", "proguard-annotations") - includeModule("com.facebook.fbjni", "fbjni-java-only") - includeModule("com.facebook.fresco", "fresco") - includeModule("com.facebook.fresco", "stetho") - includeModule("com.facebook.fresco", "fbcore") - includeModule("com.facebook.fresco", "drawee") - includeModule("com.facebook.fresco", "imagepipeline") - includeModule("com.facebook.fresco", "imagepipeline-native") - includeModule("com.facebook.fresco", "memory-type-native") - includeModule("com.facebook.fresco", "memory-type-java") - includeModule("com.facebook.fresco", "nativeimagefilters") - includeModule("com.facebook.stetho", "stetho") - includeModule("com.wei.android.lib", "fingerprintidentify") - } + // react-native-background-fetch ships com.transistorsoft:tsbackgroundfetch + // as a bundled local Maven repo; the package's own build.gradle adds it + // for itself, but :app's runtime classpath resolution needs it visible + // at the root level too. + maven { + url("$rootDir/../node_modules/react-native-background-fetch/android/libs") } + mavenCentral { // We don't want to fetch react-native from Maven Central as there are // older versions over there. @@ -61,36 +73,112 @@ allprojects { excludeGroup "com.facebook.react" } } - mavenLocal() - maven { - // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm - url("$rootDir/../node_modules/react-native/android") - } - maven { - // Android JSC is installed from npm - url("$rootDir/../node_modules/jsc-android/dist") - } google() maven { url 'https://www.jitpack.io' } } } -subprojects { - afterEvaluate {project -> - if (project.hasProperty("android")) { +// Apply jcenter shim very early for every project before its build.gradle is evaluated +gradle.beforeProject { project -> + addJcenterShim(project.repositories, project.logger) + if (project.buildscript != null) { + addJcenterShim(project.buildscript.repositories, project.logger) + } +} + +// Apply to root as well +addJcenterShim(repositories, logger) +if (buildscript != null) { + addJcenterShim(buildscript.repositories, logger) +} + +subprojects { project -> + // react-native-device-info's androidTest classpath pulls + // play-services-iid:16.0.1 -> play-services-base:16.0.1 -> support-v4:26.1.0, + // which collides with androidx.core:core:1.13.1 (Duplicate class + // android.support.v4.app.INotificationSideChannel). Exclude the pre-AndroidX + // support-* modules so the AndroidX equivalents in core win. + configurations.all { + exclude group: 'com.android.support', module: 'support-compat' + exclude group: 'com.android.support', module: 'support-annotations' + exclude group: 'com.android.support', module: 'support-core-utils' + } + + // Remove and block any jcenter() repositories at both project and buildscript levels + def scrub = { repoContainer -> + repoContainer.all { repo -> + if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository && + repo.url?.toString()?.contains('jcenter')) { + project.logger.lifecycle("Removing jcenter() from ${project.path}") + repoContainer.remove(repo) + } + } + repoContainer.whenObjectAdded { repo -> + if (repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository && + repo.url?.toString()?.contains('jcenter')) { + project.logger.lifecycle("Blocking jcenter() from ${project.path}") + repoContainer.remove(repo) + repoContainer.mavenCentral() + } + } + } + + scrub(project.repositories) + if (project.buildscript != null) { + scrub(project.buildscript.repositories) + } + + afterEvaluate {proj -> + if (proj.hasProperty("android")) { android { - buildToolsVersion '30.0.3' - compileSdkVersion 31 + buildToolsVersion "36.0.0" + compileSdkVersion 36 defaultConfig { - minSdkVersion 28 + minSdkVersion 24 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } } } + + tasks.withType(AbstractArchiveTask).configureEach { + preserveFileTimestamps = false + reproducibleFileOrder = true + } + + tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile) { + // FIXME: next line should be removed when https://github.com/wix/Detox/issues/4678 is fixed + kotlinOptions.freeCompilerArgs += ["-Xopt-in=kotlin.ExperimentalStdlibApi"] + if (proj.plugins.hasPlugin("com.android.application") || proj.plugins.hasPlugin("com.android.library")) { + kotlinOptions.jvmTarget = android.compileOptions.sourceCompatibility + } else { + kotlinOptions.jvmTarget = sourceCompatibility + } + kotlinOptions.jvmTarget = "17" + } + + if (proj.name == "react-native-reanimated" && proj.hasProperty("android")) { + // Wire Reanimated's generated codegen sources so WorkletsModule spec is visible under new architecture + proj.android.sourceSets.main.java.srcDir("${proj.buildDir}/generated/source/codegen/java") + } + } } -subprojects { subproject -> - if(project['name'] == 'react-native-widget-center') { - project.configurations { compile { } } +// Final guard: fail fast if any jcenter repository slips through +gradle.projectsEvaluated { + allprojects { proj -> + def offenders = proj.repositories.findAll { repo -> + repo instanceof org.gradle.api.artifacts.repositories.MavenArtifactRepository && + repo.url?.toString()?.contains('jcenter') + } + if (!offenders.isEmpty()) { + throw new GradleException("jcenter() detected in ${proj.path}; remove or replace with mavenCentral()"); } + } } + +apply plugin: "com.facebook.react.rootproject" \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index cc86a726d93..7b4ae66fc81 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -10,7 +10,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m -org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit @@ -21,9 +21,6 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true -# Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true - # Use this property to specify which architecture you want to build. # You can also override it from the CLI using # ./gradlew -PreactNativeArchitectures=x86_64 @@ -34,8 +31,16 @@ reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 # your application. You should enable this flag either if you want # to write custom TurboModules/Fabric components OR use libraries that # are providing them. -newArchEnabled=false +newArchEnabled=true # Use this property to enable or disable the Hermes JS engine. # If set to false, you will be using JSC instead. -hermesEnabled=true \ No newline at end of file +hermesEnabled=true + +# Use this property to enable edge-to-edge display support. +# This allows your app to draw behind system bars for an immersive UI. +# Note: Only works with ReactActivity and should not be used with custom Activity. +edgeToEdgeEnabled=true + +# Use legacy NDK symbol upload for Bugsnag (avoids v5.26.0 requirement) +bugsnag.useLegacyNdkSymbolUpload=true \ No newline at end of file diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar index 5c2d1cf016b..61285a659d1 100644 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 7cbb1e48ee1..37f78a6af83 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Tue Jul 21 23:04:55 CDT 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip diff --git a/android/gradlew b/android/gradlew index a58591e97bc..ad3611bb2e3 100755 --- a/android/gradlew +++ b/android/gradlew @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,8 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +170,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,18 +197,27 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. @@ -231,4 +244,4 @@ eval "set -- $( tr '\n' ' ' )" '"$@"' -exec "$JAVACMD" "$@" \ No newline at end of file +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat index e3ecc7d7777..90a3f3c50fc 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -5,7 +5,7 @@ @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem -@rem http://www.apache.org/licenses/LICENSE-2.0 +@rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @@ -13,8 +13,10 @@ @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,10 +27,14 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @@ -37,13 +43,13 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail @@ -53,45 +59,32 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 goto fail -:init -@rem Get command-line arguments, handling Windows variants - -if not "%OS%" == "Windows_NT" goto win9xME_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* - :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/android/settings.gradle b/android/settings.gradle index f003f339a7b..2221f878143 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,6 +1,9 @@ +pluginManagement { includeBuild("../node_modules/@react-native/gradle-plugin") } +plugins { id("com.facebook.react.settings") } +extensions.configure(com.facebook.react.ReactSettingsExtension){ ex -> ex.autolinkLibrariesFromCommand() } rootProject.name = 'BlueWallet' -apply from: file("../node_modules/@react-native-community/cli-platform-android/native_modules.gradle"); applyNativeModulesSettingsGradle(settings) include ':app' -includeBuild('../node_modules/react-native-gradle-plugin') +includeBuild('../node_modules/@react-native/gradle-plugin') include ':detox' -project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox') \ No newline at end of file +project(':detox').projectDir = new File(rootProject.projectDir, '../node_modules/detox/android/detox') + diff --git a/appcenter-post-build.sh b/appcenter-post-build.sh deleted file mode 100755 index eeff0bf8a39..00000000000 --- a/appcenter-post-build.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/sh - -echo Uploading to Appetize and publishing link to Github... -echo "Branch " -BRANCH=`git ls-remote --heads origin | grep $(git rev-parse HEAD) | cut -d / -f 3` -echo $BRANCH -echo "Branch 2 " -git log -n 1 --pretty=%d HEAD | awk '{print $2}' | sed 's/origin\///' | sed 's/)//' - -FILENAME="$APPCENTER_OUTPUT_DIRECTORY/app-release.apk" - -if [ -f $FILENAME ]; then - APTZ=`curl "https://$APPETIZE@api.appetize.io/v1/apps" -F "file=@$FILENAME" -F "platform=android"` - echo Apptezize response: - echo $APTZ - APPURL=`node -e "let e = JSON.parse('$APTZ'); console.log(e.publicURL + '?device=pixel4');"` - echo App url: $APPURL - PR=`node scripts/appcenter-post-build-get-pr-number.js` - echo PR: $PR - - DLOAD_APK="https://lambda-download-android-build.herokuapp.com/download/$BUILD_BUILDID" - - curl -X POST --data "{\"body\":\"♫ This was a triumph. I'm making a note here: HUGE SUCCESS ♫\n\n [android in browser] $APPURL\n\n[download apk]($DLOAD_APK) \"}" -u "$GITHUB" "https://api.github.com/repos/BlueWallet/BlueWallet/issues/$PR/comments" -fi diff --git a/babel.config.js b/babel.config.js index fff45f944a1..92957922925 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,4 +1,7 @@ module.exports = { - presets: ['module:metro-react-native-babel-preset'], - plugins: ['react-native-reanimated/plugin'], // required by react-native-reanimated v2 https://docs.swmansion.com/react-native-reanimated/docs/installation/ + // Pin the @babel/runtime version so Metro resolves a single copy instead of + // bundling duplicate helpers, which bloats the bundle. + // See https://github.com/babel/babel/issues/18050 + presets: [['module:@react-native/babel-preset', { enableBabelRuntime: '^7.26.0' }]], + plugins: ['react-native-worklets/plugin'], }; diff --git a/blue_modules/BlueElectrum.d.ts b/blue_modules/BlueElectrum.d.ts deleted file mode 100644 index a51dfaef162..00000000000 --- a/blue_modules/BlueElectrum.d.ts +++ /dev/null @@ -1,98 +0,0 @@ -type Utxo = { - height: number; - value: number; - address: string; - txId: string; - vout: number; - wif?: string; -}; - -export type ElectrumTransaction = { - txid: string; - hash: string; - version: number; - size: number; - vsize: number; - weight: number; - locktime: number; - vin: { - txid: string; - vout: number; - scriptSig: { asm: string; hex: string }; - txinwitness: string[]; - sequence: number; - addresses?: string[]; - value?: number; - }[]; - vout: { - value: number; - n: number; - scriptPubKey: { - asm: string; - hex: string; - reqSigs: number; - type: string; - addresses: string[]; - }; - }[]; - blockhash: string; - confirmations?: number; - time: number; - blocktime: number; -}; - -type MempoolTransaction = { - height: 0; - tx_hash: string; - fee: number; -}; - -export async function connectMain(): Promise; - -export async function waitTillConnected(): Promise; - -export function forceDisconnect(): void; - -export function getBalanceByAddress(address: string): Promise<{ confirmed: number; unconfirmed: number }>; - -export function multiGetUtxoByAddress(addresses: string[]): Promise>; - -// TODO: this function returns different results based on the value of `verbose`, consider splitting it into two -export function multiGetTransactionByTxid( - txIds: string[], - batchsize: number = 45, - verbose: true = true, -): Promise>; -export function multiGetTransactionByTxid(txIds: string[], batchsize: number, verbose: false): Promise>; - -export type MultiGetBalanceResponse = { - balance: number; - unconfirmed_balance: number; - addresses: Record; -}; - -export function multiGetBalanceByAddress(addresses: string[], batchsize?: number): Promise; - -export function getTransactionsByAddress(address: string): ElectrumTransaction[]; - -export function getMempoolTransactionsByAddress(address: string): Promise; - -export function estimateCurrentBlockheight(): number; - -export type ElectrumHistory = { - tx_hash: string; - height: number; - address: string; -}; - -export function multiGetHistoryByAddress(addresses: string[]): Promise>; - -export function estimateFees(): Promise<{ fast: number; medium: number; slow: number }>; - -export function broadcastV2(txhex: string): Promise; - -export function getTransactionsFullByAddress(address: string): Promise; - -export function txhexToElectrumTransaction(txhes: string): ElectrumTransaction; - -export function isDisabled(): Promise; diff --git a/blue_modules/BlueElectrum.js b/blue_modules/BlueElectrum.js deleted file mode 100644 index 79ede14583b..00000000000 --- a/blue_modules/BlueElectrum.js +++ /dev/null @@ -1,1092 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { Alert } from 'react-native'; -import { LegacyWallet, SegwitBech32Wallet, SegwitP2SHWallet, TaprootWallet } from '../class'; -import DefaultPreference from 'react-native-default-preference'; -import loc from '../loc'; -import WidgetCommunication from './WidgetCommunication'; -import { isTorDaemonDisabled } from './environment'; -import alert from '../components/Alert'; -const bitcoin = require('bitcoinjs-lib'); -const ElectrumClient = require('electrum-client'); -const reverse = require('buffer-reverse'); -const BigNumber = require('bignumber.js'); -const torrific = require('./torrific'); -const Realm = require('realm'); - -const ELECTRUM_HOST = 'electrum_host'; -const ELECTRUM_TCP_PORT = 'electrum_tcp_port'; -const ELECTRUM_SSL_PORT = 'electrum_ssl_port'; -const ELECTRUM_SERVER_HISTORY = 'electrum_server_history'; -const ELECTRUM_CONNECTION_DISABLED = 'electrum_disabled'; - -let _realm; -async function _getRealm() { - if (_realm) return _realm; - - const password = bitcoin.crypto.sha256(Buffer.from('fyegjitkyf[eqjnc.lf')).toString('hex'); - const buf = Buffer.from(password + password, 'hex'); - const encryptionKey = Int8Array.from(buf); - const path = 'electrumcache.realm'; - - const schema = [ - { - name: 'Cache', - primaryKey: 'cache_key', - properties: { - cache_key: { type: 'string', indexed: true }, - cache_value: 'string', // stringified json - }, - }, - ]; - _realm = await Realm.open({ - schema, - path, - encryptionKey, - }); - return _realm; -} - -const storageKey = 'ELECTRUM_PEERS'; -const defaultPeer = { host: 'electrum1.bluewallet.io', ssl: '443' }; -const hardcodedPeers = [ - { host: 'mainnet.foundationdevices.com', ssl: '50002' }, - { host: 'bitcoin.lukechilds.co', ssl: '50002' }, - { host: 'electrum.jochen-hoenicke.de', ssl: '50006' }, - { host: 'electrum1.bluewallet.io', ssl: '443' }, - { host: 'electrum.acinq.co', ssl: '50002' }, - { host: 'electrum.bitaroo.net', ssl: '50002' }, -]; - -/** @type {ElectrumClient} */ -let mainClient; -let mainConnected = false; -let wasConnectedAtLeastOnce = false; -let serverName = false; -let disableBatching = false; -let connectionAttempt = 0; -let currentPeerIndex = Math.floor(Math.random() * hardcodedPeers.length); - -let latestBlockheight = false; -let latestBlockheightTimestamp = false; - -const txhashHeightCache = {}; - -async function isDisabled() { - let result; - try { - const savedValue = await AsyncStorage.getItem(ELECTRUM_CONNECTION_DISABLED); - if (savedValue === null) { - result = false; - } else { - result = savedValue; - } - } catch { - result = false; - } - return !!result; -} - -async function setDisabled(disabled = true) { - return AsyncStorage.setItem(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : ''); -} - -async function connectMain() { - if (await isDisabled()) { - console.log('Electrum connection disabled by user. Skipping connectMain call'); - return; - } - let usingPeer = await getNextPeer(); - const savedPeer = await getSavedPeer(); - if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) { - usingPeer = savedPeer; - } - - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - try { - if (usingPeer.host.endsWith('onion')) { - const randomPeer = await getCurrentPeer(); - await DefaultPreference.set(ELECTRUM_HOST, randomPeer.host); - await DefaultPreference.set(ELECTRUM_TCP_PORT, randomPeer.tcp); - await DefaultPreference.set(ELECTRUM_SSL_PORT, randomPeer.ssl); - } else { - await DefaultPreference.set(ELECTRUM_HOST, usingPeer.host); - await DefaultPreference.set(ELECTRUM_TCP_PORT, usingPeer.tcp); - await DefaultPreference.set(ELECTRUM_SSL_PORT, usingPeer.ssl); - } - - WidgetCommunication.reloadAllTimelines(); - } catch (e) { - // Must be running on Android - console.log(e); - } - - try { - console.log('begin connection:', JSON.stringify(usingPeer)); - mainClient = new ElectrumClient( - usingPeer.host.endsWith('.onion') && !(await isTorDaemonDisabled()) ? torrific : global.net, - global.tls, - usingPeer.ssl || usingPeer.tcp, - usingPeer.host, - usingPeer.ssl ? 'tls' : 'tcp', - ); - - mainClient.onError = function (e) { - console.log('electrum mainClient.onError():', e.message); - if (mainConnected) { - // most likely got a timeout from electrum ping. lets reconnect - // but only if we were previously connected (mainConnected), otherwise theres other - // code which does connection retries - mainClient.close(); - mainConnected = false; - // dropping `mainConnected` flag ensures there wont be reconnection race condition if several - // errors triggered - console.log('reconnecting after socket error'); - setTimeout(connectMain, usingPeer.host.endsWith('.onion') ? 4000 : 500); - } - }; - const ver = await mainClient.initElectrum({ client: 'bluewallet', version: '1.4' }); - if (ver && ver[0]) { - console.log('connected to ', ver); - serverName = ver[0]; - mainConnected = true; - wasConnectedAtLeastOnce = true; - if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) { - disableBatching = true; - - // exeptions for versions: - const [electrumImplementation, electrumVersion] = ver[0].split(' '); - switch (electrumImplementation) { - case 'electrs': - if (semVerToInt(electrumVersion) >= semVerToInt('0.9.0')) { - disableBatching = false; - } - break; - case 'electrs-esplora': - // its a different one, and it does NOT support batching - // nop - break; - case 'Fulcrum': - if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) { - disableBatching = false; - } - break; - } - } - const header = await mainClient.blockchainHeaders_subscribe(); - if (header && header.height) { - latestBlockheight = header.height; - latestBlockheightTimestamp = Math.floor(+new Date() / 1000); - } - // AsyncStorage.setItem(storageKey, JSON.stringify(peers)); TODO: refactor - } - } catch (e) { - mainConnected = false; - console.log('bad connection:', JSON.stringify(usingPeer), e); - } - - if (!mainConnected) { - console.log('retry'); - connectionAttempt = connectionAttempt + 1; - mainClient.close && mainClient.close(); - if (connectionAttempt >= 5) { - presentNetworkErrorAlert(usingPeer); - } else { - console.log('reconnection attempt #', connectionAttempt); - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - return connectMain(); - } - } -} - -async function presentNetworkErrorAlert(usingPeer) { - if (await isDisabled()) { - console.log( - 'Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.', - ); - return; - } - Alert.alert( - loc.errors.network, - loc.formatString( - usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect, - usingPeer ? { server: `${usingPeer.host}:${usingPeer.ssl ?? usingPeer.tcp}` } : {}, - ), - [ - { - text: loc.wallets.list_tryagain, - onPress: () => { - connectionAttempt = 0; - mainClient.close() && mainClient.close(); - setTimeout(connectMain, 500); - }, - style: 'default', - }, - { - text: loc.settings.electrum_reset, - onPress: () => { - Alert.alert( - loc.settings.electrum_reset, - loc.settings.electrum_reset_to_default, - [ - { - text: loc._.cancel, - style: 'cancel', - onPress: () => {}, - }, - { - text: loc._.ok, - style: 'destructive', - onPress: async () => { - await AsyncStorage.setItem(ELECTRUM_HOST, ''); - await AsyncStorage.setItem(ELECTRUM_TCP_PORT, ''); - await AsyncStorage.setItem(ELECTRUM_SSL_PORT, ''); - try { - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - await DefaultPreference.clear(ELECTRUM_HOST); - await DefaultPreference.clear(ELECTRUM_SSL_PORT); - await DefaultPreference.clear(ELECTRUM_TCP_PORT); - WidgetCommunication.reloadAllTimelines(); - } catch (e) { - // Must be running on Android - console.log(e); - } - alert(loc.settings.electrum_saved); - setTimeout(connectMain, 500); - }, - }, - ], - { cancelable: true }, - ); - connectionAttempt = 0; - mainClient.close() && mainClient.close(); - }, - style: 'destructive', - }, - { - text: loc._.cancel, - onPress: () => { - connectionAttempt = 0; - mainClient.close() && mainClient.close(); - }, - style: 'cancel', - }, - ], - { cancelable: false }, - ); -} - -async function getCurrentPeer() { - return hardcodedPeers[currentPeerIndex]; -} - -/** - * Returns NEXT hardcoded electrum server (increments index after use) - * - * @returns {Promise<{tcp, host, ssl?}|*>} - */ -async function getNextPeer() { - const peer = getCurrentPeer(); - currentPeerIndex++; - if (currentPeerIndex + 1 >= hardcodedPeers.length) currentPeerIndex = 0; - return peer; -} - -async function getSavedPeer() { - const host = await AsyncStorage.getItem(ELECTRUM_HOST); - const port = await AsyncStorage.getItem(ELECTRUM_TCP_PORT); - const sslPort = await AsyncStorage.getItem(ELECTRUM_SSL_PORT); - return { host, tcp: port, ssl: sslPort }; -} - -/** - * Returns random electrum server out of list of servers - * previous electrum server told us. Nearly half of them is - * usually offline. - * Not used for now. - * - * @returns {Promise<{tcp: number, host: string}>} - */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -async function getRandomDynamicPeer() { - try { - let peers = JSON.parse(await AsyncStorage.getItem(storageKey)); - peers = peers.sort(() => Math.random() - 0.5); // shuffle - for (const peer of peers) { - const ret = {}; - ret.host = peer[1]; - for (const item of peer[2]) { - if (item.startsWith('t')) { - ret.tcp = item.replace('t', ''); - } - } - if (ret.host && ret.tcp) return ret; - } - - return defaultPeer; // failed to find random client, using default - } catch (_) { - return defaultPeer; // smth went wrong, using default - } -} - -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getBalanceByAddress = async function (address) { - if (!mainClient) throw new Error('Electrum client is not connected'); - const script = bitcoin.address.toOutputScript(address); - const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); - const balance = await mainClient.blockchainScripthash_getBalance(reversedHash.toString('hex')); - balance.addr = address; - return balance; -}; - -module.exports.getConfig = async function () { - if (!mainClient) throw new Error('Electrum client is not connected'); - return { - host: mainClient.host, - port: mainClient.port, - serverName, - connected: mainClient.timeLastCall !== 0 && mainClient.status, - }; -}; - -module.exports.getSecondsSinceLastRequest = function () { - return mainClient && mainClient.timeLastCall ? (+new Date() - mainClient.timeLastCall) / 1000 : -1; -}; - -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getTransactionsByAddress = async function (address) { - if (!mainClient) throw new Error('Electrum client is not connected'); - const script = bitcoin.address.toOutputScript(address); - const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); - const history = await mainClient.blockchainScripthash_getHistory(reversedHash.toString('hex')); - for (const h of history || []) { - if (h.tx_hash) txhashHeightCache[h.tx_hash] = h.height; // cache tx height - } - - return history; -}; - -/** - * - * @param address {String} - * @returns {Promise} - */ -module.exports.getMempoolTransactionsByAddress = async function (address) { - if (!mainClient) throw new Error('Electrum client is not connected'); - const script = bitcoin.address.toOutputScript(address); - const hash = bitcoin.crypto.sha256(script); - const reversedHash = Buffer.from(reverse(hash)); - return mainClient.blockchainScripthash_getMempool(reversedHash.toString('hex')); -}; - -module.exports.ping = async function () { - try { - await mainClient.server_ping(); - } catch (_) { - mainConnected = false; - return false; - } - return true; -}; - -module.exports.getTransactionsFullByAddress = async function (address) { - const txs = await this.getTransactionsByAddress(address); - const ret = []; - for (const tx of txs) { - let full; - try { - full = await mainClient.blockchainTransaction_get(tx.tx_hash, true); - } catch (error) { - if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { - // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started - // throwing a proper exception. lets fetch txhex manually and decode on our end - const txhex = await mainClient.blockchainTransaction_get(tx.tx_hash, false); - full = txhexToElectrumTransaction(txhex); - } else { - // nope, its something else - throw new Error(String(error?.message ?? error)); - } - } - full.address = address; - for (const input of full.vin) { - // now we need to fetch previous TX where this VIN became an output, so we can see its amount - let prevTxForVin; - try { - prevTxForVin = await mainClient.blockchainTransaction_get(input.txid, true); - } catch (error) { - if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { - // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started - // throwing a proper exception. lets fetch txhex manually and decode on our end - const txhex = await mainClient.blockchainTransaction_get(input.txid, false); - prevTxForVin = txhexToElectrumTransaction(txhex); - } else { - // nope, its something else - throw new Error(String(error?.message ?? error)); - } - } - if (prevTxForVin && prevTxForVin.vout && prevTxForVin.vout[input.vout]) { - input.value = prevTxForVin.vout[input.vout].value; - // also, we extract destination address from prev output: - if (prevTxForVin.vout[input.vout].scriptPubKey && prevTxForVin.vout[input.vout].scriptPubKey.addresses) { - input.addresses = prevTxForVin.vout[input.vout].scriptPubKey.addresses; - } - // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: - if (prevTxForVin.vout[input.vout]?.scriptPubKey?.address) { - input.addresses = [prevTxForVin.vout[input.vout].scriptPubKey.address]; - } - } - } - - for (const output of full.vout) { - if (output.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses; - // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: - if (output?.scriptPubKey?.address) output.addresses = [output.scriptPubKey.address]; - } - full.inputs = full.vin; - full.outputs = full.vout; - delete full.vin; - delete full.vout; - delete full.hex; // compact - delete full.hash; // compact - ret.push(full); - } - - return ret; -}; - -/** - * - * @param addresses {Array} - * @param batchsize {Number} - * @returns {Promise<{balance: number, unconfirmed_balance: number, addresses: object}>} - */ -module.exports.multiGetBalanceByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 200; - if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = { balance: 0, unconfirmed_balance: 0, addresses: {} }; - - const chunks = splitIntoChunks(addresses, batchsize); - for (const chunk of chunks) { - const scripthashes = []; - const scripthash2addr = {}; - for (const addr of chunk) { - const script = bitcoin.address.toOutputScript(addr); - const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); - scripthashes.push(reversedHash); - scripthash2addr[reversedHash] = addr; - } - - let balances = []; - - if (disableBatching) { - const promises = []; - const index2scripthash = {}; - for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { - promises.push(mainClient.blockchainScripthash_getBalance(scripthashes[promiseIndex])); - index2scripthash[promiseIndex] = scripthashes[promiseIndex]; - } - const promiseResults = await Promise.all(promises); - for (let resultIndex = 0; resultIndex < promiseResults.length; resultIndex++) { - balances.push({ result: promiseResults[resultIndex], param: index2scripthash[resultIndex] }); - } - } else { - balances = await mainClient.blockchainScripthash_getBalanceBatch(scripthashes); - } - - for (const bal of balances) { - if (bal.error) console.warn('multiGetBalanceByAddress():', bal.error); - ret.balance += +bal.result.confirmed; - ret.unconfirmed_balance += +bal.result.unconfirmed; - ret.addresses[scripthash2addr[bal.param]] = bal.result; - } - } - - return ret; -}; - -module.exports.multiGetUtxoByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 100; - if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; - - const chunks = splitIntoChunks(addresses, batchsize); - for (const chunk of chunks) { - const scripthashes = []; - const scripthash2addr = {}; - for (const addr of chunk) { - const script = bitcoin.address.toOutputScript(addr); - const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); - scripthashes.push(reversedHash); - scripthash2addr[reversedHash] = addr; - } - - let results = []; - - if (disableBatching) { - // ElectrumPersonalServer doesnt support `blockchain.scripthash.listunspent` - // electrs OTOH supports it, but we dont know it we are currently connected to it or to EPS - // so it is pretty safe to do nothing, as caller can derive UTXO from stored transactions - } else { - results = await mainClient.blockchainScripthash_listunspentBatch(scripthashes); - } - - for (const utxos of results) { - ret[scripthash2addr[utxos.param]] = utxos.result; - for (const utxo of ret[scripthash2addr[utxos.param]]) { - utxo.address = scripthash2addr[utxos.param]; - utxo.txId = utxo.tx_hash; - utxo.vout = utxo.tx_pos; - delete utxo.tx_pos; - delete utxo.tx_hash; - } - } - } - - return ret; -}; - -module.exports.multiGetHistoryByAddress = async function (addresses, batchsize) { - batchsize = batchsize || 100; - if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; - - const chunks = splitIntoChunks(addresses, batchsize); - for (const chunk of chunks) { - const scripthashes = []; - const scripthash2addr = {}; - for (const addr of chunk) { - const script = bitcoin.address.toOutputScript(addr); - const hash = bitcoin.crypto.sha256(script); - let reversedHash = Buffer.from(reverse(hash)); - reversedHash = reversedHash.toString('hex'); - scripthashes.push(reversedHash); - scripthash2addr[reversedHash] = addr; - } - - let results = []; - - if (disableBatching) { - const promises = []; - const index2scripthash = {}; - for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { - index2scripthash[promiseIndex] = scripthashes[promiseIndex]; - promises.push(mainClient.blockchainScripthash_getHistory(scripthashes[promiseIndex])); - } - const histories = await Promise.all(promises); - for (let historyIndex = 0; historyIndex < histories.length; historyIndex++) { - results.push({ result: histories[historyIndex], param: index2scripthash[historyIndex] }); - } - } else { - results = await mainClient.blockchainScripthash_getHistoryBatch(scripthashes); - } - - for (const history of results) { - if (history.error) console.warn('multiGetHistoryByAddress():', history.error); - ret[scripthash2addr[history.param]] = history.result || []; - for (const result of history.result || []) { - if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height - } - - for (const hist of ret[scripthash2addr[history.param]]) { - hist.address = scripthash2addr[history.param]; - } - } - } - - return ret; -}; - -module.exports.multiGetTransactionByTxid = async function (txids, batchsize, verbose = true) { - batchsize = batchsize || 45; - // this value is fine-tuned so althrough wallets in test suite will occasionally - // throw 'response too large (over 1,000,000 bytes', test suite will pass - if (!mainClient) throw new Error('Electrum client is not connected'); - const ret = {}; - txids = [...new Set(txids)]; // deduplicate just for any case - - // lets try cache first: - const realm = await _getRealm(); - const cacheKeySuffix = verbose ? '_verbose' : '_non_verbose'; - const keysCacheMiss = []; - for (const txid of txids) { - const jsonString = realm.objectForPrimaryKey('Cache', txid + cacheKeySuffix); // search for a realm object with a primary key - if (jsonString && jsonString.cache_value) { - try { - ret[txid] = JSON.parse(jsonString.cache_value); - } catch (error) { - console.log(error, 'cache failed to parse', jsonString.cache_value); - } - } - - if (!ret[txid]) keysCacheMiss.push(txid); - } - - if (keysCacheMiss.length === 0) { - return ret; - } - - txids = keysCacheMiss; - // end cache - - const chunks = splitIntoChunks(txids, batchsize); - for (const chunk of chunks) { - let results = []; - - if (disableBatching) { - try { - // in case of ElectrumPersonalServer it might not track some transactions (like source transactions for our transactions) - // so we wrap it in try-catch. note, when `Promise.all` fails we will get _zero_ results, but we have a fallback for that - const promises = []; - const index2txid = {}; - for (let promiseIndex = 0; promiseIndex < chunk.length; promiseIndex++) { - const txid = chunk[promiseIndex]; - index2txid[promiseIndex] = txid; - promises.push(mainClient.blockchainTransaction_get(txid, verbose)); - } - - const transactionResults = await Promise.all(promises); - for (let resultIndex = 0; resultIndex < transactionResults.length; resultIndex++) { - let tx = transactionResults[resultIndex]; - if (typeof tx === 'string' && verbose) { - // apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx. - // lets decode it manually on our end then: - tx = txhexToElectrumTransaction(tx); - } - const txid = index2txid[resultIndex]; - results.push({ result: tx, param: txid }); - } - } catch (_) { - if (String(_?.message ?? _).startsWith('verbose transactions are currently unsupported')) { - // electrs-esplora. cant use verbose, so fetching txs one by one and decoding locally - for (const txid of chunk) { - try { - let tx = await mainClient.blockchainTransaction_get(txid, false); - tx = txhexToElectrumTransaction(tx); - results.push({ result: tx, param: txid }); - } catch (error) { - console.log(error); - } - } - } else { - // fallback. pretty sure we are connected to EPS. we try getting transactions one-by-one. this way we wont - // fail and only non-tracked by EPS transactions will be omitted - for (const txid of chunk) { - try { - let tx = await mainClient.blockchainTransaction_get(txid, verbose); - if (typeof tx === 'string' && verbose) { - // apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx. - // lets decode it manually on our end then: - tx = txhexToElectrumTransaction(tx); - } - results.push({ result: tx, param: txid }); - } catch (error) { - console.log(error); - } - } - } - } - } else { - results = await mainClient.blockchainTransaction_getBatch(chunk, verbose); - } - - for (const txdata of results) { - if (txdata.error && txdata.error.code === -32600) { - // response too large - // lets do single call, that should go through okay: - txdata.result = await mainClient.blockchainTransaction_get(txdata.param, false); - // since we used VERBOSE=false, server sent us plain txhex which we must decode on our end: - txdata.result = txhexToElectrumTransaction(txdata.result); - } - ret[txdata.param] = txdata.result; - if (ret[txdata.param]) delete ret[txdata.param].hex; // compact - } - } - - // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: - for (const txid of Object.keys(ret) ?? []) { - for (const vout of ret[txid]?.vout ?? []) { - if (vout?.scriptPubKey?.address) vout.scriptPubKey.addresses = [vout.scriptPubKey.address]; - } - } - - // saving cache: - realm.write(() => { - for (const txid of Object.keys(ret)) { - if (verbose && (!ret[txid].confirmations || ret[txid].confirmations < 7)) continue; - // dont cache immature txs, but only for 'verbose', since its fully decoded tx jsons. non-verbose are just plain - // strings txhex - realm.create( - 'Cache', - { - cache_key: txid + cacheKeySuffix, - cache_value: JSON.stringify(ret[txid]), - }, - Realm.UpdateMode.Modified, - ); - } - }); - - return ret; -}; - -/** - * Simple waiter till `mainConnected` becomes true (which means - * it Electrum was connected in other function), or timeout 30 sec. - * - * - * @returns {Promise | Promise<*>>} - */ -module.exports.waitTillConnected = async function () { - let waitTillConnectedInterval = false; - let retriesCounter = 0; - if (await isDisabled()) { - console.warn('Electrum connections disabled by user. waitTillConnected skipping...'); - return; - } - return new Promise(function (resolve, reject) { - waitTillConnectedInterval = setInterval(() => { - if (mainConnected) { - clearInterval(waitTillConnectedInterval); - return resolve(true); - } - - if (wasConnectedAtLeastOnce && mainClient.status === 1) { - clearInterval(waitTillConnectedInterval); - mainConnected = true; - return resolve(true); - } - - if (wasConnectedAtLeastOnce && retriesCounter++ >= 150) { - // `wasConnectedAtLeastOnce` needed otherwise theres gona be a race condition with the code that connects - // electrum during app startup - clearInterval(waitTillConnectedInterval); - presentNetworkErrorAlert(); - reject(new Error('Waiting for Electrum connection timeout')); - } - }, 100); - }); -}; - -// Returns the value at a given percentile in a sorted numeric array. -// "Linear interpolation between closest ranks" method -function percentile(arr, p) { - if (arr.length === 0) return 0; - if (typeof p !== 'number') throw new TypeError('p must be a number'); - if (p <= 0) return arr[0]; - if (p >= 1) return arr[arr.length - 1]; - - const index = (arr.length - 1) * p; - const lower = Math.floor(index); - const upper = lower + 1; - const weight = index % 1; - - if (upper >= arr.length) return arr[lower]; - return arr[lower] * (1 - weight) + arr[upper] * weight; -} - -/** - * The histogram is an array of [fee, vsize] pairs, where vsizen is the cumulative virtual size of mempool transactions - * with a fee rate in the interval [feen-1, feen], and feen-1 > feen. - * - * @param numberOfBlocks {Number} - * @param feeHistorgram {Array} - * @returns {number} - */ -module.exports.calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks, feeHistorgram) { - // first, transforming histogram: - let totalVsize = 0; - const histogramToUse = []; - for (const h of feeHistorgram) { - let [fee, vsize] = h; - let timeToStop = false; - - if (totalVsize + vsize >= 1000000 * numberOfBlocks) { - vsize = 1000000 * numberOfBlocks - totalVsize; // only the difference between current summarized sige to tip of the block - timeToStop = true; - } - - histogramToUse.push({ fee, vsize }); - totalVsize += vsize; - if (timeToStop) break; - } - - // now we have histogram of precisely size for numberOfBlocks. - // lets spread it into flat array so its easier to calculate percentile: - let histogramFlat = []; - for (const hh of histogramToUse) { - histogramFlat = histogramFlat.concat(Array(Math.round(hh.vsize / 25000)).fill(hh.fee)); - // division is needed so resulting flat array is not too huge - } - - histogramFlat = histogramFlat.sort(function (a, b) { - return a - b; - }); - - return Math.round(percentile(histogramFlat, 0.5) || 1); -}; - -module.exports.estimateFees = async function () { - let histogram; - let timeoutId; - try { - histogram = await Promise.race([ - mainClient.mempool_getFeeHistogram(), - new Promise(resolve => (timeoutId = setTimeout(resolve, 15000))), - ]); - } finally { - clearTimeout(timeoutId); - } - - if (!histogram) throw new Error('timeout while getting mempool_getFeeHistogram'); - - // fetching what electrum (which uses bitcoin core) thinks about fees: - const _fast = await module.exports.estimateFee(1); - const _medium = await module.exports.estimateFee(18); - const _slow = await module.exports.estimateFee(144); - - // calculating fast fees from mempool: - const fast = Math.max(2, module.exports.calcEstimateFeeFromFeeHistorgam(1, histogram)); - // recalculating medium and slow fees using bitcoincore estimations only like relative weights: - // (minimum 1 sat, just for any case) - const medium = Math.max(1, Math.round((fast * _medium) / _fast)); - const slow = Math.max(1, Math.round((fast * _slow) / _fast)); - return { fast, medium, slow }; -}; - -/** - * Returns the estimated transaction fee to be confirmed within a certain number of blocks - * - * @param numberOfBlocks {number} The number of blocks to target for confirmation - * @returns {Promise} Satoshis per byte - */ -module.exports.estimateFee = async function (numberOfBlocks) { - if (!mainClient) throw new Error('Electrum client is not connected'); - numberOfBlocks = numberOfBlocks || 1; - const coinUnitsPerKilobyte = await mainClient.blockchainEstimatefee(numberOfBlocks); - if (coinUnitsPerKilobyte === -1) return 1; - return Math.round(new BigNumber(coinUnitsPerKilobyte).dividedBy(1024).multipliedBy(100000000).toNumber()); -}; - -module.exports.serverFeatures = async function () { - if (!mainClient) throw new Error('Electrum client is not connected'); - return mainClient.server_features(); -}; - -module.exports.broadcast = async function (hex) { - if (!mainClient) throw new Error('Electrum client is not connected'); - try { - const broadcast = await mainClient.blockchainTransaction_broadcast(hex); - return broadcast; - } catch (error) { - return error; - } -}; - -module.exports.broadcastV2 = async function (hex) { - if (!mainClient) throw new Error('Electrum client is not connected'); - return mainClient.blockchainTransaction_broadcast(hex); -}; - -module.exports.estimateCurrentBlockheight = function () { - if (latestBlockheight) { - const timeDiff = Math.floor(+new Date() / 1000) - latestBlockheightTimestamp; - const extraBlocks = Math.floor(timeDiff / (9.93 * 60)); - return latestBlockheight + extraBlocks; - } - - const baseTs = 1587570465609; // uS - const baseHeight = 627179; - return Math.floor(baseHeight + (+new Date() - baseTs) / 1000 / 60 / 9.93); -}; - -/** - * - * @param height - * @returns {number} Timestamp in seconds - */ -module.exports.calculateBlockTime = function (height) { - if (latestBlockheight) { - return Math.floor(latestBlockheightTimestamp + (height - latestBlockheight) * 9.93 * 60); - } - - const baseTs = 1585837504; // sec - const baseHeight = 624083; - return Math.floor(baseTs + (height - baseHeight) * 9.93 * 60); -}; - -/** - * - * @param host - * @param tcpPort - * @param sslPort - * @returns {Promise} Whether provided host:port is a valid electrum server - */ -module.exports.testConnection = async function (host, tcpPort, sslPort) { - const isTorDisabled = await isTorDaemonDisabled(); - const client = new ElectrumClient( - host.endsWith('.onion') && !isTorDisabled ? torrific : global.net, - global.tls, - sslPort || tcpPort, - host, - sslPort ? 'tls' : 'tcp', - ); - - client.onError = () => {}; // mute - let timeoutId = false; - try { - const rez = await Promise.race([ - new Promise(resolve => { - timeoutId = setTimeout(() => resolve('timeout'), host.endsWith('.onion') && !isTorDisabled ? 21000 : 5000); - }), - client.connect(), - ]); - if (rez === 'timeout') return false; - - await client.server_version('2.7.11', '1.4'); - await client.server_ping(); - return true; - } catch (_) { - } finally { - if (timeoutId) clearTimeout(timeoutId); - client.close(); - } - - return false; -}; - -module.exports.forceDisconnect = () => { - mainClient.close(); -}; - -module.exports.setBatchingDisabled = () => { - disableBatching = true; -}; - -module.exports.setBatchingEnabled = () => { - disableBatching = false; -}; -module.exports.connectMain = connectMain; -module.exports.isDisabled = isDisabled; -module.exports.setDisabled = setDisabled; -module.exports.hardcodedPeers = hardcodedPeers; -module.exports.ELECTRUM_HOST = ELECTRUM_HOST; -module.exports.ELECTRUM_TCP_PORT = ELECTRUM_TCP_PORT; -module.exports.ELECTRUM_SSL_PORT = ELECTRUM_SSL_PORT; -module.exports.ELECTRUM_SERVER_HISTORY = ELECTRUM_SERVER_HISTORY; - -const splitIntoChunks = function (arr, chunkSize) { - const groups = []; - let i; - for (i = 0; i < arr.length; i += chunkSize) { - groups.push(arr.slice(i, i + chunkSize)); - } - return groups; -}; - -const semVerToInt = function (semver) { - if (!semver) return 0; - if (semver.split('.').length !== 3) return 0; - - const ret = semver.split('.')[0] * 1000000 + semver.split('.')[1] * 1000 + semver.split('.')[2] * 1; - - if (isNaN(ret)) return 0; - - return ret; -}; - -function txhexToElectrumTransaction(txhex) { - const tx = bitcoin.Transaction.fromHex(txhex); - - const ret = { - txid: tx.getId(), - hash: tx.getId(), - version: tx.version, - size: Math.ceil(txhex.length / 2), - vsize: tx.virtualSize(), - weight: tx.weight(), - locktime: tx.locktime, - vin: [], - vout: [], - hex: txhex, - blockhash: '', - confirmations: 0, - time: 0, - blocktime: 0, - }; - - if (txhashHeightCache[ret.txid]) { - // got blockheight where this tx was confirmed - ret.confirmations = module.exports.estimateCurrentBlockheight() - txhashHeightCache[ret.txid]; - if (ret.confirmations < 0) { - // ugly fix for when estimator lags behind - ret.confirmations = 1; - } - ret.time = module.exports.calculateBlockTime(txhashHeightCache[ret.txid]); - ret.blocktime = module.exports.calculateBlockTime(txhashHeightCache[ret.txid]); - } - - for (const inn of tx.ins) { - const txinwitness = []; - if (inn.witness[0]) txinwitness.push(inn.witness[0].toString('hex')); - if (inn.witness[1]) txinwitness.push(inn.witness[1].toString('hex')); - - ret.vin.push({ - txid: reverse(inn.hash).toString('hex'), - vout: inn.index, - scriptSig: { hex: inn.script.toString('hex'), asm: '' }, - txinwitness, - sequence: inn.sequence, - }); - } - - let n = 0; - for (const out of tx.outs) { - const value = new BigNumber(out.value).dividedBy(100000000).toNumber(); - let address = false; - let type = false; - - if (SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = SegwitBech32Wallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = 'witness_v0_keyhash'; - } else if (SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = SegwitP2SHWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = '???'; // TODO - } else if (LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex'))) { - address = LegacyWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = '???'; // TODO - } else { - address = TaprootWallet.scriptPubKeyToAddress(out.script.toString('hex')); - type = 'witness_v1_taproot'; - } - - ret.vout.push({ - value, - n, - scriptPubKey: { - asm: '', - hex: out.script.toString('hex'), - reqSigs: 1, // todo - type, - addresses: [address], - }, - }); - n++; - } - return ret; -} - -// exported only to be used in unit tests -module.exports.txhexToElectrumTransaction = txhexToElectrumTransaction; diff --git a/blue_modules/BlueElectrum.ts b/blue_modules/BlueElectrum.ts new file mode 100644 index 00000000000..01da22932b3 --- /dev/null +++ b/blue_modules/BlueElectrum.ts @@ -0,0 +1,1651 @@ +import BigNumber from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; +import DefaultPreference from 'react-native-default-preference'; +import RNFS from 'react-native-fs'; +import Realm from 'realm'; +import { sha256 as _sha256 } from '@noble/hashes/sha256'; + +import type { LegacyWallet as LegacyWalletT } from '../class/wallets/legacy-wallet'; +import type { SegwitBech32Wallet as SegwitBech32WalletT } from '../class/wallets/segwit-bech32-wallet'; +import type { SegwitP2SHWallet as SegwitP2SHWalletT } from '../class/wallets/segwit-p2sh-wallet'; +import type { TaprootWallet as TaprootWalletT } from '../class/wallets/taproot-wallet'; +import presentAlert from '../components/Alert'; +import loc from '../loc'; +import { GROUP_IO_BLUEWALLET } from './currency'; +import { ElectrumServerItem } from '../screen/settings/ElectrumSettings'; +import { triggerWarningHapticFeedback } from './hapticFeedback'; +import { AlertButton } from 'react-native'; +import { uint8ArrayToHex, stringToUint8Array, hexToUint8Array } from './uint8array-extras/index'; + +const ElectrumClient = require('electrum-client'); +const net = require('net'); +const tls = require('tls'); + +type Utxo = { + height: number; + value: number; + address: string; + txid: string; + vout: number; + wif?: string; +}; + +export type ElectrumTransaction = { + txid: string; + hash: string; + version: number; + size: number; + vsize: number; + weight: number; + locktime: number; + vin: { + txid: string; + vout: number; + scriptSig: { asm: string; hex: string }; + txinwitness: string[]; + sequence: number; + addresses?: string[]; + value?: number; + }[]; + vout: { + value: number; + n: number; + scriptPubKey: { + asm: string; + hex: string; + reqSigs: number; + type: string; + addresses: string[]; + }; + }[]; + // Confirmation-only fields: absent on mempool (unconfirmed) responses. + blockhash?: string; + confirmations?: number; + time?: number; + blocktime?: number; +}; + +export type ElectrumTransactionWithHex = ElectrumTransaction & { + hex: string; +}; + +type MempoolTransaction = { + height: 0; + tx_hash: string; + fee: number; +}; + +type Peer = { + host: string; + ssl?: number; + tcp?: number; +}; + +export const ELECTRUM_HOST = 'electrum_host'; +export const ELECTRUM_TCP_PORT = 'electrum_tcp_port'; +export const ELECTRUM_SSL_PORT = 'electrum_ssl_port'; +export const ELECTRUM_SERVER_HISTORY = 'electrum_server_history'; +const ELECTRUM_CONNECTION_DISABLED = 'electrum_disabled'; +const storageKey = 'ELECTRUM_PEERS'; +const defaultPeer = { host: 'electrum1.bluewallet.io', ssl: 443 }; +export const hardcodedPeers: Peer[] = [ + { host: 'mainnet.foundationdevices.com', ssl: 50002 }, + { host: 'bitcoin.lu.ke', ssl: 50002 }, + // { host: 'electrum.jochen-hoenicke.de', ssl: '50006' }, + { host: 'electrum1.bluewallet.io', ssl: 443 }, + { host: 'electrum.acinq.co', ssl: 50002 }, +]; + +export const suggestedServers: Peer[] = hardcodedPeers.map(peer => ({ + ...peer, +})); + +let mainClient: typeof ElectrumClient | undefined; +let serverName: string | false = false; +let disableBatching: boolean = false; +let currentPeerIndex = hardcodedPeers.findIndex(peer => peer.host === defaultPeer.host && peer.ssl === defaultPeer.ssl); +if (currentPeerIndex < 0) currentPeerIndex = 0; +let latestBlock: { height: number; time: number } | { height: undefined; time: undefined } = { height: undefined, time: undefined }; + +// --- Single source of truth for connection liveness ----------------------------- +// We previously tracked `mainConnected` (boolean) separately from the client's own +// `mainClient.status`. They drifted on iOS suspend/resume: a transient `ping()` +// failure cleared the flag while the socket was still alive, then `waitTillConnected` +// blocked for ~30s on the stale flag and surfaced a false network-error alert. The +// state machine + `ensureConnected()` below is the only place that mutates the +// connection lifecycle, and UI is driven by subscribing to state changes. + +export type ConnectionState = 'disabled' | 'disconnected' | 'connecting' | 'connected'; +let connState: ConnectionState = 'disconnected'; +type ConnectionListener = (state: ConnectionState) => void; +const connectionListeners = new Set(); + +function setConnectionState(next: ConnectionState): void { + if (connState === next) return; + connState = next; + for (const l of connectionListeners) { + try { + l(next); + } catch (e) { + console.warn('[electrum] connection listener threw:', e); + } + } +} + +/** Current connection state for UI. */ +export function getConnectionState(): ConnectionState { + return connState; +} + +/** Subscribe to state changes. Returns an unsubscribe function. */ +export function subscribeConnectionState(listener: ConnectionListener): () => void { + connectionListeners.add(listener); + return () => { + connectionListeners.delete(listener); + }; +} + +/** Convenience: `true` iff a usable Electrum connection is currently believed to exist. */ +export function isConnected(): boolean { + return connState === 'connected'; +} + +// --- Connection lifecycle internals --------------------------------------------- +/** One liveness check (`server_ping`) wall-time before giving up and marking the socket dead. */ +const PING_TIMEOUT_MS = 5_000; +/** One full connect attempt (TLS + `server_version` handshake) wall-time before retrying. */ +const CONNECT_ATTEMPT_TIMEOUT_MS = 10_000; +/** Reconnect attempts inside a single `ensureConnected()` call before declaring failure. */ +const CONNECT_MAX_ATTEMPTS = 5; +/** Backoff between attempts to avoid hammering a flaky server. */ +const CONNECT_BACKOFF_MS = 500; +/** Delay before the auto-reconnect triggered by a live-socket `onError`. Onions are slower. */ +const RECONNECT_ONION_DELAY_MS = 4_000; +const RECONNECT_TCP_DELAY_MS = 500; + +/** Max wall time one `ensureConnected()` call may take when no live socket exists. */ +export const ENSURE_CONNECTED_MAX_WALL_MS = + CONNECT_MAX_ATTEMPTS * CONNECT_ATTEMPT_TIMEOUT_MS + (CONNECT_MAX_ATTEMPTS - 1) * CONNECT_BACKOFF_MS; + +/** Coalesces concurrent `ensureConnected()` callers — at most one connect attempt at a time. */ +let ensureInFlight: Promise | null = null; +/** If any coalesced caller asked for the failure alert, honour it once the in-flight attempt finishes. */ +let ensureInFlightShowAlert = false; + +/** + * Bumps every time the caller asks us to abandon the current connection + * (`forceDisconnect()` or user disabling Electrum). In-flight `ensureConnected()` + * checks this between attempts so it can bail out promptly instead of racing back + * to `connected` after a disconnect was requested. + */ +let disconnectGeneration = 0; +const txhashHeightCache: Record = {}; +let _realm: Realm | undefined; + +function bitcoinjs_crypto_sha256(buffer: Uint8Array): Uint8Array { + return _sha256(buffer); +} + +async function _getRealm() { + if (_realm) return _realm; + + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder + const password = uint8ArrayToHex(bitcoinjs_crypto_sha256(stringToUint8Array('fyegjitkyf[eqjnc.lf'))); + const buf = hexToUint8Array(password + password); + const encryptionKey = Int8Array.from(buf); + const path = `${cacheFolderPath}/electrumcache.realm`; // Use cache folder path + + const schema = [ + { + name: 'Cache', + primaryKey: 'cache_key', + properties: { + cache_key: { type: 'string', indexed: true }, + cache_value: 'string', // stringified json + }, + }, + ]; + + // @ts-ignore schema doesn't match Realm's schema type + _realm = await Realm.open({ + schema, + path, + encryptionKey, + excludeFromIcloudBackup: true, + }); + + return _realm; +} + +export const getPreferredServer = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const host = (await DefaultPreference.get(ELECTRUM_HOST)) as string; + const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT); + const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT); + + console.log('[electrum] Getting preferred server:', { host, tcpPort, sslPort }); + + if (!host) { + console.warn('[electrum] Preferred server host is undefined'); + return; + } + + return { + host, + tcp: tcpPort ? Number(tcpPort) : undefined, + ssl: sslPort ? Number(sslPort) : undefined, + }; + } catch (error) { + console.error('[electrum] Error in getPreferredServer:', error); + return undefined; + } +}; + +export const removePreferredServer = async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + console.log('[electrum] Removing preferred server'); + await DefaultPreference.clear(ELECTRUM_HOST); + await DefaultPreference.clear(ELECTRUM_TCP_PORT); + await DefaultPreference.clear(ELECTRUM_SSL_PORT); + } catch (error) { + console.error('[electrum] Error in removePreferredServer:', error); + } +}; + +export async function isDisabled(): Promise { + let result; + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const savedValue = await DefaultPreference.get(ELECTRUM_CONNECTION_DISABLED); + console.log('[electrum] Getting Electrum connection disabled state:', savedValue); + if (savedValue === null) { + result = false; + } else { + result = savedValue; + } + } catch (error) { + console.error('[electrum] Error getting Electrum connection disabled state:', error); + result = false; + } + return !!result; +} + +export async function setDisabled(disabled = true) { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + console.log('[electrum] Setting Electrum connection disabled state to:', disabled); + const result = await DefaultPreference.set(ELECTRUM_CONNECTION_DISABLED, disabled ? '1' : ''); + // Disabling must abort any in-flight ensureConnected() and tear down the live + // socket so callers don't have to remember to pair this with forceDisconnect(). + // Without bumping the generation, an in-flight connect could race back to + // 'connected' after the user toggled Electrum off. + if (disabled) { + disconnectGeneration += 1; + if (mainClient) { + try { + mainClient.close(); + } catch {} + mainClient = undefined; + } + setConnectionState('disabled'); + } + return result; +} + +function getCurrentPeer() { + return hardcodedPeers[currentPeerIndex]; +} + +/** + * Returns NEXT hardcoded electrum server (increments index after use) + */ +function getNextPeer() { + const peer = getCurrentPeer(); + currentPeerIndex++; + if (currentPeerIndex >= hardcodedPeers.length) currentPeerIndex = 0; + return peer; +} + +async function getSavedPeer(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const host = (await DefaultPreference.get(ELECTRUM_HOST)) as string; + const tcpPort = await DefaultPreference.get(ELECTRUM_TCP_PORT); + const sslPort = await DefaultPreference.get(ELECTRUM_SSL_PORT); + + console.log('[electrum] Getting saved peer:', { host, tcpPort, sslPort }); + + if (!host) { + return null; + } + + if (sslPort) { + return { host, ssl: Number(sslPort) }; + } + + if (tcpPort) { + return { host, tcp: Number(tcpPort) }; + } + + return null; + } catch (error) { + console.error('[electrum] Error in getSavedPeer:', error); + return null; + } +} + +/** Resolve to the peer this attempt should target (preferred saved peer, or rotate hardcoded list). */ +async function pickPeer(): Promise { + let usingPeer = getNextPeer(); + const savedPeer = await getSavedPeer(); + if (savedPeer && savedPeer.host && (savedPeer.tcp || savedPeer.ssl)) { + usingPeer = savedPeer; + } + return usingPeer; +} + +function scheduleReconnectFromClient(client: typeof ElectrumClient, usingPeer: Peer, reason: string): void { + if (connState !== 'connected' || mainClient !== client) return; + + console.log(`[electrum] scheduling Electrum reconnect after ${reason}`); + try { + // Also neutralises electrum-client's own timers/reconnect hooks for this instance. + client.close(); + } catch {} + if (mainClient === client) mainClient = undefined; + setConnectionState('disconnected'); + + const delay = usingPeer.host.endsWith('.onion') ? RECONNECT_ONION_DELAY_MS : RECONNECT_TCP_DELAY_MS; + const generationAtSchedule = disconnectGeneration; + setTimeout(() => { + if (generationAtSchedule !== disconnectGeneration) return; + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file + ensureConnected().catch(() => { + /* ensureConnected never throws, but be defensive */ + }); + }, delay); +} + +/** + * One connect attempt: build a fresh `ElectrumClient`, run the version handshake, + * subscribe to headers. No retries, no UI side effects. Returns the peer used + * (for caller-side telemetry/alerts) and whether the attempt succeeded. + */ +async function attemptConnectOnce(): Promise<{ ok: boolean; peer: Peer }> { + const usingPeer = await pickPeer(); + console.log('[electrum] Using peer:', JSON.stringify(usingPeer)); + + // Drop any prior client before allocating a new one. Closing also neutralises + // electrum-client's internal `reconnect()` loop on the old instance. + if (mainClient) { + try { + mainClient.close(); + } catch {} + mainClient = undefined; + } + + try { + console.log('[electrum] begin connection:', JSON.stringify(usingPeer)); + const client = new ElectrumClient(net, tls, usingPeer.ssl || usingPeer.tcp, usingPeer.host, usingPeer.ssl ? 'tls' : 'tcp'); + mainClient = client; + + // Live-socket errors after a successful handshake: schedule a single + // `ensureConnected()` (deduped). Errors during this attempt's own handshake + // are caught below — we must not double-handle them here. + client.onError = function (e: { message: string }) { + console.log('[electrum] electrum mainClient.onError():', e.message); + scheduleReconnectFromClient(client, usingPeer, 'socket error'); + }; + + const ver = await Promise.race([ + client.initElectrum( + { client: 'bluewallet', version: '1.4' }, + { + maxRetry: 0, + callback: () => scheduleReconnectFromClient(client, usingPeer, 'socket close'), + }, + ), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error('connect timeout')), CONNECT_ATTEMPT_TIMEOUT_MS)), + ]); + + if (mainClient !== client) { + // Caller raced `forceDisconnect()` while we were awaiting. Bail. + try { + client.close(); + } catch {} + return { ok: false, peer: usingPeer }; + } + + if (ver && ver[0]) { + console.log('[electrum] connected to ', ver); + serverName = ver[0]; + if (ver[0].startsWith('ElectrumPersonalServer') || ver[0].startsWith('electrs') || ver[0].startsWith('Fulcrum')) { + disableBatching = true; + const [electrumImplementation, electrumVersion] = ver[0].split(' '); + switch (electrumImplementation) { + case 'electrs': + if (semVerToInt(electrumVersion) >= semVerToInt('0.9.0')) { + disableBatching = false; + } + break; + case 'electrs-esplora': + break; + case 'Fulcrum': + if (semVerToInt(electrumVersion) >= semVerToInt('1.9.0')) { + disableBatching = false; + } + break; + } + } + const header = await client.blockchainHeaders_subscribe(); + if (header && header.height) { + latestBlock = { + height: header.height, + time: Math.floor(+new Date() / 1000), + }; + } + return { ok: true, peer: usingPeer }; + } + return { ok: false, peer: usingPeer }; + } catch (e) { + console.log('[electrum] bad connection:', JSON.stringify(usingPeer), e); + if (mainClient) { + try { + mainClient.close(); + } catch {} + mainClient = undefined; + } + return { ok: false, peer: usingPeer }; + } +} + +/** Single liveness check on the current `mainClient`, bounded by `PING_TIMEOUT_MS`. */ +async function pingWithTimeout(timeoutMs: number = PING_TIMEOUT_MS): Promise { + if (!mainClient) return false; + const client = mainClient; + try { + await Promise.race([ + client.server_ping(), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error('ping timeout')), timeoutMs)), + ]); + return mainClient === client; // server replied AND client wasn't swapped while we waited + } catch { + return false; + } +} + +export type EnsureConnectedOptions = { + /** + * Show the legacy "couldn't connect" alert (Try again / Reset / Cancel) on failure. + * Used by initial bootstrap (`SettingsProvider` re-enabling Electrum) and the manual + * help alert. Off-hot-path callers (refresh, broadcast, etc.) should leave this false + * and surface their own UI. + */ + showAlertOnFailure?: boolean; +}; + +/** + * Make sure a usable Electrum connection exists, healing if needed. + * + * - If we already think we're connected, run one fast `ping` to verify. If the ping + * succeeds, we're done. If it fails the client is torn down and we fall through + * to a reconnect. + * - Otherwise run up to `CONNECT_MAX_ATTEMPTS` connect attempts (each with its own + * timeout + backoff). + * + * Concurrent callers share the same in-flight promise — there is at most one connect + * attempt at a time per process. This replaces the old `mainConnected`-flag-polling + * `waitTillConnected()`, which could block ~30s on a stale flag while the socket was + * still alive. + */ +export async function ensureConnected(opts: EnsureConnectedOptions = {}): Promise { + const { showAlertOnFailure = false } = opts; + + if (await isDisabled()) { + setConnectionState('disabled'); + return false; + } + + if (ensureInFlight) { + if (showAlertOnFailure) ensureInFlightShowAlert = true; + return ensureInFlight; + } + + ensureInFlightShowAlert = showAlertOnFailure; + ensureInFlight = (async (): Promise => { + const myGeneration = disconnectGeneration; + /** True iff the current generation no longer matches ours (i.e. `forceDisconnect()` ran). */ + const aborted = (where: string): boolean => { + if (myGeneration === disconnectGeneration) return false; + console.log(`[electrum] ensureConnected aborted by forceDisconnect at ${where} (gen ${myGeneration} → ${disconnectGeneration})`); + return true; + }; + let lastPeer: Peer | undefined; + try { + // Fast path: live ping on the existing client. + if (mainClient && connState === 'connected') { + if (await pingWithTimeout()) { + // If a disconnect/disable raced us, the bumper already set the right + // state ('disconnected' or 'disabled'); don't clobber it from here. + if (aborted('post-ping')) return false; + return true; + } + // Stale socket. Tear it down so the attempt loop starts fresh. + try { + mainClient.close(); + } catch {} + mainClient = undefined; + setConnectionState('disconnected'); + } + + if (aborted('pre-loop')) return false; + setConnectionState('connecting'); + + for (let i = 0; i < CONNECT_MAX_ATTEMPTS; i++) { + if (await isDisabled()) { + setConnectionState('disabled'); + return false; + } + // Generation-bumper (`forceDisconnect` or `setDisabled(true)`) already + // set the appropriate terminal state; we must not clobber 'disabled' + // back to 'disconnected' here. + if (aborted(`attempt ${i} start`)) return false; + + const { ok, peer } = await attemptConnectOnce(); + lastPeer = peer; + + if (aborted(`attempt ${i} end`)) { + if (mainClient) { + try { + mainClient.close(); + } catch {} + mainClient = undefined; + } + return false; + } + if (ok) { + setConnectionState('connected'); + return true; + } + if (i < CONNECT_MAX_ATTEMPTS - 1) { + await new Promise(resolve => setTimeout(resolve, CONNECT_BACKOFF_MS)); + } + } + + setConnectionState('disconnected'); + if (ensureInFlightShowAlert) { + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- defined later in file + presentNetworkErrorAlert(lastPeer); + } + return false; + } finally { + ensureInFlight = null; + ensureInFlightShowAlert = false; + } + })(); + + return ensureInFlight; +} + +export async function presentResetToDefaultsAlert(): Promise { + const hasPreferredServer = await getPreferredServer(); + const serverHistoryStr = await DefaultPreference.get(ELECTRUM_SERVER_HISTORY); + const serverHistory = typeof serverHistoryStr === 'string' ? JSON.parse(serverHistoryStr) : []; + return new Promise(resolve => { + triggerWarningHapticFeedback(); + + const buttons: AlertButton[] = []; + + if (hasPreferredServer?.host && (hasPreferredServer.tcp || hasPreferredServer.ssl)) { + buttons.push({ + text: loc.settings.electrum_reset, + onPress: async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.clear(ELECTRUM_HOST); + await DefaultPreference.clear(ELECTRUM_SSL_PORT); + await DefaultPreference.clear(ELECTRUM_TCP_PORT); + } catch (e) { + console.log('[electrum]', e); // Must be running on Android + } + resolve(true); + }, + style: 'default', + }); + } + + if (serverHistory.length > 0) { + buttons.push({ + text: loc.settings.electrum_reset_to_default_and_clear_history, + onPress: async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.clear(ELECTRUM_SERVER_HISTORY); + await DefaultPreference.clear(ELECTRUM_HOST); + await DefaultPreference.clear(ELECTRUM_SSL_PORT); + await DefaultPreference.clear(ELECTRUM_TCP_PORT); + } catch (e) { + console.log('[electrum]', e); // Must be running on Android + } + resolve(true); + }, + style: 'destructive', + }); + } + + buttons.push({ + text: loc._.cancel, + onPress: () => resolve(false), + style: 'cancel', + }); + + presentAlert({ + title: loc.settings.electrum_reset, + message: loc.settings.electrum_reset_to_default, + buttons, + options: { cancelable: true }, + }); + }); +} + +async function presentNetworkErrorAlert(usingPeer?: Peer, allowRepeat = false) { + if (await isDisabled()) { + console.log( + '[electrum] Electrum connection disabled by user. Perhaps we are attempting to show this network error alert after the user disabled connections.', + ); + return; + } + + presentAlert({ + allowRepeat, + title: loc.errors.network, + message: loc.formatString( + usingPeer ? loc.settings.electrum_unable_to_connect : loc.settings.electrum_error_connect, + usingPeer ? { server: `${usingPeer.host}:${usingPeer.ssl ?? usingPeer.tcp}` } : {}, + ), + buttons: [ + { + text: loc.wallets.list_tryagain, + onPress: () => { + forceDisconnect(); + setTimeout(() => { + ensureConnected({ showAlertOnFailure: true }).catch(() => {}); + }, 500); + }, + style: 'default', + }, + { + text: loc.settings.electrum_reset, + onPress: () => { + presentResetToDefaultsAlert().then(result => { + if (result) { + forceDisconnect(); + setTimeout(() => { + ensureConnected({ showAlertOnFailure: true }).catch(() => {}); + }, 500); + } + }); + }, + style: 'destructive', + }, + { + text: loc._.cancel, + onPress: () => { + forceDisconnect(); + }, + style: 'cancel', + }, + ], + options: { cancelable: false }, + }); +} + +/** + * Wallets list header when Electrum looks disconnected: same actions as the internal timeout alert, with allowRepeat so the user can open it again after dismiss. + */ +export async function presentElectrumDisconnectedHelpAlert(): Promise { + await presentNetworkErrorAlert(undefined, true); +} + +/** + * Returns random electrum server out of list of servers + * previous electrum server told us. Nearly half of them is + * usually offline. + * Not used for now. + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function getRandomDynamicPeer(): Promise { + try { + let peers = JSON.parse((await DefaultPreference.get(storageKey)) as string); + peers = peers.sort(() => Math.random() - 0.5); // shuffle + for (const peer of peers) { + const ret: Peer = { host: peer[0], ssl: peer[1] }; + ret.host = peer[1]; + + if (peer[1] === 's') { + ret.ssl = peer[2]; + } else { + ret.tcp = peer[2]; + } + + for (const item of peer[2]) { + if (item.startsWith('t')) { + ret.tcp = item.replace('t', ''); + } + } + if (ret.host && ret.tcp) return ret; + } + + return defaultPeer; // failed to find random client, using default + } catch (_) { + return defaultPeer; // smth went wrong, using default + } +} + +export const getBalanceByAddress = async function (address: string): Promise<{ confirmed: number; unconfirmed: number }> { + try { + if (!mainClient) throw new Error('Electrum client is not connected'); + const script = bitcoin.address.toOutputScript(address); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = new Uint8Array(hash).reverse(); + const balance = await mainClient.blockchainScripthash_getBalance(uint8ArrayToHex(reversedHash)); + balance.addr = address; + return balance; + } catch (error) { + console.error('[electrum] Error in getBalanceByAddress:', error); + throw error; + } +}; + +export const getConfig = async function () { + if (!mainClient) { + return { + host: undefined, + port: undefined, + serverName: false as typeof serverName, + connected: connState === 'connected' ? 1 : 0, + }; + } + return { + host: mainClient.host, + port: mainClient.port, + serverName, + // Drive UI "connected" indicator from the single state machine so the settings + // screen agrees with the wallets-list header pill and with `ensureConnected()`. + connected: connState === 'connected' ? 1 : 0, + }; +}; + +export const getSecondsSinceLastRequest = function () { + return mainClient && mainClient.timeLastCall ? (+new Date() - mainClient.timeLastCall) / 1000 : -1; +}; + +export const getTransactionsByAddress = async function (address: string): Promise { + if (!mainClient) throw new Error('Electrum client is not connected'); + const script = bitcoin.address.toOutputScript(address); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = new Uint8Array(hash).reverse(); + const history = await mainClient.blockchainScripthash_getHistory(uint8ArrayToHex(reversedHash)); + for (const h of history || []) { + if (h.tx_hash) txhashHeightCache[h.tx_hash] = h.height; // cache tx height + } + + return history; +}; + +export const getMempoolTransactionsByAddress = async function (address: string): Promise { + if (!mainClient) throw new Error('Electrum client is not connected'); + const script = bitcoin.address.toOutputScript(address); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = new Uint8Array(hash).reverse(); + return mainClient.blockchainScripthash_getMempool(uint8ArrayToHex(reversedHash)); +}; + +/** + * Read-only liveness probe. Does NOT trigger reconnects (use `ensureConnected()` + * for that). Updates the connection state machine to reflect the probe result so + * subscribers (UI pill, settings screen) stay in sync. + * + * - `true`: server replied within `PING_TIMEOUT_MS`. + * - `false`: client missing, timed out, or server errored. + */ +export const ping = async function (): Promise { + if (await isDisabled()) return false; + const ok = await pingWithTimeout(); + if (ok) { + // Heal stale `disconnected` state from a transient ping failure earlier. + if (connState !== 'connected') setConnectionState('connected'); + } else if (connState === 'connected') { + setConnectionState('disconnected'); + } + return ok; +}; + +// exported only to be used in unit tests +export function txhexToElectrumTransaction(txhex: string): ElectrumTransactionWithHex { + const tx = bitcoin.Transaction.fromHex(txhex); + + const ret: ElectrumTransactionWithHex = { + txid: tx.getId(), + hash: tx.getId(), + version: tx.version, + size: Math.ceil(txhex.length / 2), + vsize: tx.virtualSize(), + weight: tx.weight(), + locktime: tx.locktime, + vin: [], + vout: [], + hex: txhex, + blockhash: '', + confirmations: 0, + time: 0, + blocktime: 0, + }; + + if (txhashHeightCache[ret.txid]) { + // got blockheight where this tx was confirmed + ret.confirmations = estimateCurrentBlockheight() - txhashHeightCache[ret.txid]; + if (ret.confirmations < 0) { + // ugly fix for when estimator lags behind + ret.confirmations = 1; + } + ret.time = calculateBlockTime(txhashHeightCache[ret.txid]); + ret.blocktime = calculateBlockTime(txhashHeightCache[ret.txid]); + } + + for (const inn of tx.ins) { + const txinwitness = []; + if (inn.witness[0]) txinwitness.push(uint8ArrayToHex(inn.witness[0])); + if (inn.witness[1]) txinwitness.push(uint8ArrayToHex(inn.witness[1])); + + ret.vin.push({ + txid: uint8ArrayToHex(new Uint8Array(inn.hash).reverse()), + vout: inn.index, + scriptSig: { hex: uint8ArrayToHex(inn.script), asm: '' }, + txinwitness, + sequence: inn.sequence, + }); + } + + let n = 0; + for (const out of tx.outs) { + const value = new BigNumber(out.value).dividedBy(100000000).toNumber(); + let address: false | string = false; + let type: false | string = false; + + // Lazy require to avoid the module-scope cycle described above. These + // modules are fully loaded by the time this function is actually invoked. + const { SegwitBech32Wallet } = require('../class/wallets/segwit-bech32-wallet') as { + SegwitBech32Wallet: typeof SegwitBech32WalletT; + }; + const { SegwitP2SHWallet } = require('../class/wallets/segwit-p2sh-wallet') as { + SegwitP2SHWallet: typeof SegwitP2SHWalletT; + }; + const { LegacyWallet } = require('../class/wallets/legacy-wallet') as { + LegacyWallet: typeof LegacyWalletT; + }; + const { TaprootWallet } = require('../class/wallets/taproot-wallet') as { + TaprootWallet: typeof TaprootWalletT; + }; + + if (SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) { + address = SegwitBech32Wallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script)); + type = 'witness_v0_keyhash'; + } else if (SegwitP2SHWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) { + address = SegwitP2SHWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script)); + type = '???'; // TODO + } else if (LegacyWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script))) { + address = LegacyWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script)); + type = '???'; // TODO + } else { + address = TaprootWallet.scriptPubKeyToAddress(uint8ArrayToHex(out.script)); + type = 'witness_v1_taproot'; + } + + if (!address) { + throw new Error('Internal error: unable to decode address from output script'); + } + + ret.vout.push({ + value, + n, + scriptPubKey: { + asm: '', + hex: uint8ArrayToHex(out.script), + reqSigs: 1, // todo + type, + addresses: [address], + }, + }); + n++; + } + return ret; +} + +export const getTransactionsFullByAddress = async (address: string): Promise => { + const txs = await getTransactionsByAddress(address); + const ret: ElectrumTransaction[] = []; + for (const tx of txs) { + let full; + try { + full = await mainClient.blockchainTransaction_get(tx.tx_hash, true); + } catch (error: any) { + if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { + // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started + // throwing a proper exception. lets fetch txhex manually and decode on our end + const txhex = await mainClient.blockchainTransaction_get(tx.tx_hash, false); + full = txhexToElectrumTransaction(txhex); + } else { + // nope, its something else + throw new Error(String(error?.message ?? error)); + } + } + full.address = address; + for (const input of full.vin) { + // now we need to fetch previous TX where this VIN became an output, so we can see its amount + let prevTxForVin; + try { + prevTxForVin = await mainClient.blockchainTransaction_get(input.txid, true); + } catch (error: any) { + if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { + // apparently, stupid esplora instead of returning txhex when it cant return verbose tx started + // throwing a proper exception. lets fetch txhex manually and decode on our end + const txhex = await mainClient.blockchainTransaction_get(input.txid, false); + prevTxForVin = txhexToElectrumTransaction(txhex); + } else { + // nope, its something else + throw new Error(String(error?.message ?? error)); + } + } + if (prevTxForVin && prevTxForVin.vout && prevTxForVin.vout[input.vout]) { + input.value = prevTxForVin.vout[input.vout].value; + // also, we extract destination address from prev output: + if (prevTxForVin.vout[input.vout].scriptPubKey && prevTxForVin.vout[input.vout].scriptPubKey.addresses) { + input.addresses = prevTxForVin.vout[input.vout].scriptPubKey.addresses; + } + // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: + if (prevTxForVin.vout[input.vout]?.scriptPubKey?.address) { + input.addresses = [prevTxForVin.vout[input.vout].scriptPubKey.address]; + } + } + } + + for (const output of full.vout) { + if (output?.scriptPubKey && output.scriptPubKey.addresses) output.addresses = output.scriptPubKey.addresses; + // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: + if (output?.scriptPubKey?.address) output.addresses = [output.scriptPubKey.address]; + } + full.inputs = full.vin; + full.outputs = full.vout; + delete full.vin; + delete full.vout; + delete full.hex; // compact + delete full.hash; // compact + ret.push(full); + } + + return ret; +}; + +type MultiGetBalanceResponse = { + balance: number; + unconfirmed_balance: number; + addresses: Record; +}; + +export const multiGetBalanceByAddress = async (addresses: string[], batchsize: number = 200): Promise => { + if (!mainClient) throw new Error('Electrum client is not connected'); + const ret = { + balance: 0, + unconfirmed_balance: 0, + addresses: {} as Record, + }; + + const chunks = splitIntoChunks(addresses, batchsize); + for (const chunk of chunks) { + const scripthashes = []; + const scripthash2addr: Record = {}; + for (const addr of chunk) { + const script = bitcoin.address.toOutputScript(addr); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse()); + scripthashes.push(reversedHash); + scripthash2addr[reversedHash] = addr; + } + + let balances = []; + + if (disableBatching) { + const promises = []; + const index2scripthash: Record = {}; + for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { + promises.push(mainClient.blockchainScripthash_getBalance(scripthashes[promiseIndex])); + index2scripthash[promiseIndex] = scripthashes[promiseIndex]; + } + const promiseResults = await Promise.all(promises); + for (let resultIndex = 0; resultIndex < promiseResults.length; resultIndex++) { + balances.push({ result: promiseResults[resultIndex], param: index2scripthash[resultIndex] }); + } + } else { + balances = await mainClient.blockchainScripthash_getBalanceBatch(scripthashes); + } + + for (const bal of balances) { + if (bal.error) console.warn('[electrum] multiGetBalanceByAddress():', bal.error); + ret.balance += +bal.result.confirmed; + ret.unconfirmed_balance += +bal.result.unconfirmed; + ret.addresses[scripthash2addr[bal.param]] = bal.result; + } + } + + return ret; +}; + +export const multiGetUtxoByAddress = async function (addresses: string[], batchsize: number = 100): Promise> { + if (!mainClient) throw new Error('Electrum client is not connected'); + const ret: Record = {}; + + const chunks = splitIntoChunks(addresses, batchsize); + for (const chunk of chunks) { + const scripthashes = []; + const scripthash2addr: Record = {}; + for (const addr of chunk) { + const script = bitcoin.address.toOutputScript(addr); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse()); + scripthashes.push(reversedHash); + scripthash2addr[reversedHash] = addr; + } + + let results = []; + + if (disableBatching) { + // ElectrumPersonalServer doesnt support `blockchain.scripthash.listunspent` + // electrs OTOH supports it, but we dont know it we are currently connected to it or to EPS + // so it is pretty safe to do nothing, as caller can derive UTXO from stored transactions + } else { + results = await mainClient.blockchainScripthash_listunspentBatch(scripthashes); + } + + for (const utxos of results) { + ret[scripthash2addr[utxos.param]] = utxos.result; + for (const utxo of ret[scripthash2addr[utxos.param]]) { + utxo.address = scripthash2addr[utxos.param]; + utxo.txid = utxo.tx_hash; + utxo.vout = utxo.tx_pos; + delete utxo.tx_pos; + delete utxo.tx_hash; + } + } + } + + return ret; +}; + +export type ElectrumHistory = { + tx_hash: string; + height: number; + address: string; +}; + +export const multiGetHistoryByAddress = async function ( + addresses: string[], + batchsize: number = 100, +): Promise> { + if (!mainClient) throw new Error('Electrum client is not connected'); + const ret: Record = {}; + + const chunks = splitIntoChunks(addresses, batchsize); + for (const chunk of chunks) { + const scripthashes = []; + const scripthash2addr: Record = {}; + for (const addr of chunk) { + const script = bitcoin.address.toOutputScript(addr); + const hash = bitcoinjs_crypto_sha256(script); + const reversedHash = uint8ArrayToHex(new Uint8Array(hash).reverse()); + scripthashes.push(reversedHash); + scripthash2addr[reversedHash] = addr; + } + + let results = []; + + if (disableBatching) { + const promises = []; + const index2scripthash: Record = {}; + for (let promiseIndex = 0; promiseIndex < scripthashes.length; promiseIndex++) { + index2scripthash[promiseIndex] = scripthashes[promiseIndex]; + promises.push(mainClient.blockchainScripthash_getHistory(scripthashes[promiseIndex])); + } + const histories = await Promise.all(promises); + for (let historyIndex = 0; historyIndex < histories.length; historyIndex++) { + results.push({ result: histories[historyIndex], param: index2scripthash[historyIndex] }); + } + } else { + results = await mainClient.blockchainScripthash_getHistoryBatch(scripthashes); + } + + for (const history of results) { + if (history.error) console.warn('[electrum] multiGetHistoryByAddress():', history.error); + ret[scripthash2addr[history.param]] = history.result || []; + for (const result of history.result || []) { + if (result.tx_hash) txhashHeightCache[result.tx_hash] = result.height; // cache tx height + } + + for (const hist of ret[scripthash2addr[history.param]]) { + hist.address = scripthash2addr[history.param]; + } + } + } + + return ret; +}; + +// if verbose === true ? Record : Record +type MultiGetTransactionByTxidResult = T extends true ? Record : Record; + +// TODO: this function returns different results based on the value of `verboseParam`, consider splitting it into two +export async function multiGetTransactionByTxid( + txids: string[], + verbose: T, + batchsize: number = 45, +): Promise> { + txids = txids.filter(txid => !!txid); // failsafe: removing 'undefined' or other falsy stuff from txids array + // this value is fine-tuned so althrough wallets in test suite will occasionally + // throw 'response too large (over 1,000,000 bytes', test suite will pass + if (!mainClient) throw new Error('Electrum client is not connected'); + const ret: MultiGetTransactionByTxidResult = {}; + txids = [...new Set(txids)]; // deduplicate just for any case + + // lets try cache first: + const realm = await _getRealm(); + const cacheKeySuffix = verbose ? '_verbose' : '_non_verbose'; + const keysCacheMiss = []; + for (const txid of txids) { + const jsonString = realm.objectForPrimaryKey('Cache', txid + cacheKeySuffix); // search for a realm object with a primary key + if (jsonString && jsonString.cache_value) { + try { + ret[txid] = JSON.parse(jsonString.cache_value as string); + } catch (error) { + console.log('[electrum]', error, 'cache failed to parse', jsonString.cache_value); + } + } + + if (!ret[txid]) keysCacheMiss.push(txid); + } + + if (keysCacheMiss.length === 0) { + return ret; + } + + txids = keysCacheMiss; + // end cache + + const chunks = splitIntoChunks(txids, batchsize); + for (const chunk of chunks) { + let results = []; + + if (disableBatching) { + try { + // in case of ElectrumPersonalServer it might not track some transactions (like source transactions for our transactions) + // so we wrap it in try-catch. note, when `Promise.all` fails we will get _zero_ results, but we have a fallback for that + const promises = []; + const index2txid: Record = {}; + for (let promiseIndex = 0; promiseIndex < chunk.length; promiseIndex++) { + const txid = chunk[promiseIndex]; + index2txid[promiseIndex] = txid; + promises.push(mainClient.blockchainTransaction_get(txid, verbose)); + } + + const transactionResults = await Promise.all(promises); + for (let resultIndex = 0; resultIndex < transactionResults.length; resultIndex++) { + let tx = transactionResults[resultIndex]; + if (typeof tx === 'string' && verbose) { + // apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx. + // lets decode it manually on our end then: + tx = txhexToElectrumTransaction(tx); + } + const txid = index2txid[resultIndex]; + results.push({ result: tx, param: txid }); + } + } catch (error: any) { + if (String(error?.message ?? error).startsWith('verbose transactions are currently unsupported')) { + // electrs-esplora. cant use verbose, so fetching txs one by one and decoding locally + for (const txid of chunk) { + try { + let tx = await mainClient.blockchainTransaction_get(txid, false); + tx = txhexToElectrumTransaction(tx); + results.push({ result: tx, param: txid }); + } catch (err) { + console.log('[electrum]', err); + } + } + } else { + // fallback. pretty sure we are connected to EPS. we try getting transactions one-by-one. this way we wont + // fail and only non-tracked by EPS transactions will be omitted + for (const txid of chunk) { + try { + let tx = await mainClient.blockchainTransaction_get(txid, verbose); + if (typeof tx === 'string' && verbose) { + // apparently electrum server (EPS?) didnt recognize VERBOSE parameter, and sent us plain txhex instead of decoded tx. + // lets decode it manually on our end then: + tx = txhexToElectrumTransaction(tx); + } + results.push({ result: tx, param: txid }); + } catch (err) { + console.log('[electrum]', err); + } + } + } + } + } else { + results = await mainClient.blockchainTransaction_getBatch(chunk, verbose); + } + + for (const txdata of results) { + if (txdata.error && txdata.error.code === -32600) { + // response too large + // lets do single call, that should go through okay: + txdata.result = await mainClient.blockchainTransaction_get(txdata.param, false); + // since we used VERBOSE=false, server sent us plain txhex which we must decode on our end: + txdata.result = txhexToElectrumTransaction(txdata.result); + } + ret[txdata.param] = txdata.result; + // @ts-ignore: hex property + if (ret[txdata.param]) delete ret[txdata.param].hex; // compact + } + } + + // in bitcoin core 22.0.0+ they removed `.addresses` and replaced it with plain `.address`: + for (const txid of Object.keys(ret)) { + const tx = ret[txid]; + if (typeof tx === 'string') continue; + for (const vout of tx?.vout ?? []) { + // @ts-ignore: address is not in type definition + if (vout?.scriptPubKey?.address) vout.scriptPubKey.addresses = [vout.scriptPubKey.address]; + } + } + + // saving cache: + try { + realm.write(() => { + for (const txid of Object.keys(ret)) { + const tx = ret[txid]; + // dont cache immature txs, but only for 'verbose', since its fully decoded tx jsons. non-verbose are just plain + // strings txhex + if (verbose && typeof tx !== 'string' && (!tx?.confirmations || tx.confirmations < 7)) { + continue; + } + + realm.create( + 'Cache', + { + cache_key: txid + cacheKeySuffix, + cache_value: JSON.stringify(ret[txid]), + }, + Realm.UpdateMode.Modified, + ); + } + }); + } catch (writeError) { + console.error('[electrum] Failed to write transaction cache:', writeError); + } + + return ret; +} + +// Returns the value at a given percentile in a sorted numeric array. +// "Linear interpolation between closest ranks" method +function percentile(arr: number[], p: number) { + if (arr.length === 0) return 0; + if (typeof p !== 'number') throw new TypeError('p must be a number'); + if (p <= 0) return arr[0]; + if (p >= 1) return arr[arr.length - 1]; + + const index = (arr.length - 1) * p; + const lower = Math.floor(index); + const upper = lower + 1; + const weight = index % 1; + + if (upper >= arr.length) return arr[lower]; + return arr[lower] * (1 - weight) + arr[upper] * weight; +} + +/** + * The histogram is an array of [fee, vsize] pairs, where vsizen is the cumulative virtual size of mempool transactions + * with a fee rate in the interval [feen-1, feen], and feen-1 > feen. + */ +export const calcEstimateFeeFromFeeHistorgam = function (numberOfBlocks: number, feeHistorgram: number[][]) { + // first, transforming histogram: + let totalVsize = 0; + const histogramToUse = []; + for (const h of feeHistorgram) { + let [fee, vsize] = h; + let timeToStop = false; + + if (totalVsize + vsize >= 1000000 * numberOfBlocks) { + vsize = 1000000 * numberOfBlocks - totalVsize; // only the difference between current summarized sige to tip of the block + timeToStop = true; + } + + histogramToUse.push({ fee, vsize }); + totalVsize += vsize; + if (timeToStop) break; + } + + // now we have histogram of precisely size for numberOfBlocks. + // lets spread it into flat array so its easier to calculate percentile: + let histogramFlat: number[] = []; + for (const hh of histogramToUse) { + histogramFlat = histogramFlat.concat(Array(Math.round(hh.vsize / 25000)).fill(hh.fee)); + // division is needed so resulting flat array is not too huge + } + + histogramFlat = histogramFlat.sort(function (a, b) { + return a - b; + }); + + return Math.round(percentile(histogramFlat, 0.5) || 1); +}; + +export const estimateFees = async function (): Promise<{ fast: number; medium: number; slow: number }> { + let histogram; + let timeoutId; + try { + histogram = await Promise.race([ + mainClient.mempool_getFeeHistogram(), + new Promise(resolve => (timeoutId = setTimeout(resolve, 15000))), + ]); + } finally { + clearTimeout(timeoutId); + } + + // fetching what electrum (which uses bitcoin core) thinks about fees: + const _fast = await estimateFee(1); + const _medium = await estimateFee(18); + const _slow = await estimateFee(144); + + /** + * sanity check, see + * @see https://github.com/cculianu/Fulcrum/issues/197 + * (fallback to bitcoin core estimates) + */ + if (!histogram || histogram?.[0]?.[0] > 1000) return { fast: _fast, medium: _medium, slow: _slow }; + + // calculating fast fees from mempool: + const fast = Math.max(2, calcEstimateFeeFromFeeHistorgam(1, histogram)); + // recalculating medium and slow fees using bitcoincore estimations only like relative weights: + // (minimum 1 sat, just for any case) + const medium = Math.max(1, Math.round((fast * _medium) / _fast)); + const slow = Math.max(1, Math.round((fast * _slow) / _fast)); + return { fast, medium, slow }; +}; + +/** + * Returns the estimated transaction fee to be confirmed within a certain number of blocks + * + * @param numberOfBlocks {number} The number of blocks to target for confirmation + * @returns {Promise} Satoshis per byte + */ +export const estimateFee = async function (numberOfBlocks: number): Promise { + if (!mainClient) throw new Error('Electrum client is not connected'); + numberOfBlocks = numberOfBlocks || 1; + const coinUnitsPerKilobyte = await mainClient.blockchainEstimatefee(numberOfBlocks); + if (coinUnitsPerKilobyte === -1) return 1; + return Math.ceil(new BigNumber(coinUnitsPerKilobyte).dividedBy(1024).multipliedBy(100000000).toNumber()); +}; + +export const serverFeatures = async function () { + if (!mainClient) throw new Error('Electrum client is not connected'); + return mainClient.server_features(); +}; + +export const broadcast = async function (hex: string) { + if (!mainClient) throw new Error('Electrum client is not connected'); + try { + const res = await mainClient.blockchainTransaction_broadcast(hex); + return res; + } catch (error) { + return error; + } +}; + +export const broadcastV2 = async function (hex: string): Promise { + if (!mainClient) throw new Error('Electrum client is not connected'); + try { + return await mainClient.blockchainTransaction_broadcast(hex); + } catch (error: any) { + // electrum-client rejects JSON-RPC errors as `{ code, message }`, not Error + throw new Error(error?.message || String(error)); + } +}; + +export const estimateCurrentBlockheight = function (): number { + if (latestBlock.height) { + const timeDiff = Math.floor(+new Date() / 1000) - latestBlock.time; + const extraBlocks = Math.floor(timeDiff / (9.93 * 60)); + return latestBlock.height + extraBlocks; + } + + const baseTs = 1587570465609; // uS + const baseHeight = 627179; + return Math.floor(baseHeight + (+new Date() - baseTs) / 1000 / 60 / 9.93); +}; + +export const calculateBlockTime = function (height: number): number { + if (latestBlock.height) { + return Math.floor(latestBlock.time + (height - latestBlock.height) * 9.93 * 60); + } + + const baseTs = 1585837504; // sec + const baseHeight = 624083; + return Math.floor(baseTs + (height - baseHeight) * 9.93 * 60); +}; + +/** + * @returns {Promise} Whether provided host:port is a valid electrum server + */ +export const testConnection = async function (host: string, tcpPort?: number, sslPort?: number): Promise { + const client = new ElectrumClient(net, tls, sslPort || tcpPort, host, sslPort ? 'tls' : 'tcp'); + + client.onError = () => {}; // mute + let timeoutId: NodeJS.Timeout | undefined; + const timeoutMs = host.endsWith('.onion') ? 21_000 : 5_000; + try { + const rez = await Promise.race([ + new Promise(resolve => { + timeoutId = setTimeout(() => resolve('timeout'), timeoutMs); + }), + client.connect(), + ]); + if (rez === 'timeout') return false; + + await client.server_version('2.7.11', '1.4'); + await client.server_ping(); + return true; + } catch (_) { + } finally { + if (timeoutId) clearTimeout(timeoutId); + client.close(); + } + + return false; +}; + +/** + * Drop the current connection and tell any in-flight `ensureConnected()` to abort + * (so it doesn't race the disconnect by setting state back to `connected`). + */ +export const forceDisconnect = (): void => { + disconnectGeneration += 1; + if (mainClient) { + try { + mainClient.close(); + } catch {} + mainClient = undefined; + } + setConnectionState('disconnected'); +}; + +export const setBatchingDisabled = () => { + disableBatching = true; +}; + +export const setBatchingEnabled = () => { + disableBatching = false; +}; + +export function getServerBanner(): Promise { + return mainClient.request('server.banner', []); +} + +/** Seconds before `getCurrentBlockTip()` re-requests the header from the server. */ +const TIP_CACHE_TTL_SEC = 60; + +/** Max blocks a cached tx height may lead the tip before we treat the cache entry as poisoned. */ +const MAX_CACHE_AHEAD_OF_TIP = 6; + +async function fetchBlockTipFromServer(): Promise { + if (!mainClient) { + return latestBlock.height ?? null; + } + + const now = Math.floor(+new Date() / 1000); + try { + const header = await mainClient.blockchainHeaders_subscribe(); + if (header && header.height) { + latestBlock = { height: header.height, time: now }; + return header.height; + } + } catch (e) { + console.warn('getCurrentBlockTip: subscribe failed', e); + } + + return latestBlock.height ?? null; +} + +export async function getCurrentBlockTip(): Promise { + const now = Math.floor(+new Date() / 1000); + if (latestBlock.height && now - latestBlock.time < TIP_CACHE_TTL_SEC) { + return latestBlock.height; + } + + if (!mainClient) { + if (latestBlock.height) return latestBlock.height; + throw new Error('Electrum client is not connected'); + } + + const refreshed = await fetchBlockTipFromServer(); + if (refreshed) return refreshed; + return estimateCurrentBlockheight(); +} + +export type ConfirmedBlockInfo = { height: number; tip: number }; + +/** + * Returns the confirmed block height and current tip for a given txHash. + * Both values share the same tip source so callers never mix an estimated tip with a real height. + * 1. Tries the in-memory txhashHeightCache (populated from address history). + * 2. Falls back to a DIRECT server call (bypassing Realm cache) to get fresh confirmations. + * Uses standard Bitcoin convention: height = tip - confirmations + 1. + */ +export async function getConfirmedBlockHeight(txHash: string): Promise { + let tip = await getCurrentBlockTip(); + + const cached = txhashHeightCache[txHash]; + if (cached && cached > 0) { + if (cached <= tip) { + return { height: cached, tip }; + } + + // Cached height ahead of tip: refresh tip (TTL may be stale), then re-validate. + const refreshedTip = await fetchBlockTipFromServer(); + if (refreshedTip != null) { + tip = refreshedTip; + } + if (cached <= tip) { + return { height: cached, tip }; + } + + const aheadBy = cached - tip; + if (aheadBy <= MAX_CACHE_AHEAD_OF_TIP) { + // Address-history height is trustworthy; our tip is just lagging. + return { height: cached, tip }; + } + + delete txhashHeightCache[txHash]; + } + + if (!mainClient) return null; + + try { + const verboseTx = await mainClient.blockchainTransaction_get(txHash, true); + + if (typeof verboseTx === 'string') { + // Server didn't support verbose — decode locally. + // Without txhashHeightCache entry we can't determine height. + return null; + } + + const confirmations = Number(verboseTx?.confirmations); + if (!confirmations || confirmations <= 0) return null; + + const height = tip - confirmations + 1; + if (height <= 0 || height > tip) { + return null; + } + txhashHeightCache[txHash] = height; + return { height, tip }; + } catch (e) { + console.warn('getConfirmedBlockHeight: failed', e); + return null; + } +} + +/** + * Fetches actual block header timestamps from the Electrum server for the given heights. + * Parses the 80-byte hex-encoded header to extract the 4-byte LE timestamp at byte offset 68. + */ +export async function getBlockTimestamps(heights: number[]): Promise> { + if (!mainClient) throw new Error('Electrum client is not connected'); + const result: Record = {}; + const promises = heights.map(async height => { + try { + const headerHex: string = await mainClient.blockchainBlock_header(height); + // timestamp is at bytes 68–71 of the 80-byte header (hex chars 136–143), little-endian uint32 + const tsHex = headerHex.slice(136, 144); + /* eslint-disable no-bitwise */ + const timestamp = + parseInt(tsHex.slice(0, 2), 16) | + (parseInt(tsHex.slice(2, 4), 16) << 8) | + (parseInt(tsHex.slice(4, 6), 16) << 16) | + ((parseInt(tsHex.slice(6, 8), 16) << 24) >>> 0); + /* eslint-enable no-bitwise */ + result[height] = timestamp; + } catch (e) { + console.warn(`Failed to fetch block header for height ${height}:`, e); + } + }); + await Promise.all(promises); + return result; +} + +const splitIntoChunks = function (arr: any[], chunkSize: number) { + const groups = []; + let i; + for (i = 0; i < arr.length; i += chunkSize) { + groups.push(arr.slice(i, i + chunkSize)); + } + return groups; +}; + +const semVerToInt = function (semver: string): number { + if (!semver) return 0; + if (semver.split('.').length !== 3) return 0; + + const ret = Number(semver.split('.')[0]) * 1000000 + Number(semver.split('.')[1]) * 1000 + Number(semver.split('.')[2]) * 1; + + if (isNaN(ret)) return 0; + + return ret; +}; diff --git a/blue_modules/NativeEventEmitter.ts b/blue_modules/NativeEventEmitter.ts new file mode 100644 index 00000000000..e59419807fc --- /dev/null +++ b/blue_modules/NativeEventEmitter.ts @@ -0,0 +1 @@ +export { default, type Spec } from '../codegen/NativeEventEmitter'; diff --git a/blue_modules/NativeMenuElementsEmitter.ts b/blue_modules/NativeMenuElementsEmitter.ts new file mode 100644 index 00000000000..7ae417749d0 --- /dev/null +++ b/blue_modules/NativeMenuElementsEmitter.ts @@ -0,0 +1 @@ +export { default, type Spec } from '../codegen/NativeMenuElementsEmitter'; diff --git a/blue_modules/NativeWidgetHelper.ts b/blue_modules/NativeWidgetHelper.ts new file mode 100644 index 00000000000..d621b17ab11 --- /dev/null +++ b/blue_modules/NativeWidgetHelper.ts @@ -0,0 +1 @@ +export { default, type Spec } from '../codegen/NativeWidgetHelper'; diff --git a/blue_modules/Privacy.android.tsx b/blue_modules/Privacy.android.tsx deleted file mode 100644 index 07c54680d94..00000000000 --- a/blue_modules/Privacy.android.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { useContext, useEffect } from 'react'; -// @ts-ignore: react-native-obscure is not in the type definition -import Obscure from 'react-native-obscure'; -import { BlueStorageContext } from './storage-context'; -interface PrivacyComponent extends React.FC { - enableBlur: (isPrivacyBlurEnabled: boolean) => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - const { isPrivacyBlurEnabled } = useContext(BlueStorageContext); - - useEffect(() => { - Obscure.deactivateObscure(); - }, [isPrivacyBlurEnabled]); - - return null; -}; - -Privacy.enableBlur = (isPrivacyBlurEnabled: boolean) => { - if (!isPrivacyBlurEnabled) return; - Obscure.activateObscure(); -}; - -Privacy.disableBlur = () => { - Obscure.deactivateObscure(); -}; - -export default Privacy; diff --git a/blue_modules/Privacy.ios.tsx b/blue_modules/Privacy.ios.tsx deleted file mode 100644 index 877a3131b46..00000000000 --- a/blue_modules/Privacy.ios.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { useContext, useEffect } from 'react'; -// @ts-ignore: react-native-obscure is not in the type definition -import { enabled } from 'react-native-privacy-snapshot'; -import { BlueStorageContext } from './storage-context'; - -interface PrivacyComponent extends React.FC { - enableBlur: (isPrivacyBlurEnabled: boolean) => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - const { isPrivacyBlurEnabled } = useContext(BlueStorageContext); - - useEffect(() => { - Privacy.disableBlur(); - }, [isPrivacyBlurEnabled]); - - return null; -}; - -Privacy.enableBlur = (isPrivacyBlurEnabled: boolean) => { - if (!isPrivacyBlurEnabled) return; - enabled(true); -}; - -Privacy.disableBlur = () => { - enabled(false); -}; - -export default Privacy; diff --git a/blue_modules/Privacy.tsx b/blue_modules/Privacy.tsx deleted file mode 100644 index 8feff432446..00000000000 --- a/blue_modules/Privacy.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from 'react'; - -interface PrivacyComponent extends React.FC { - enableBlur: () => void; - disableBlur: () => void; -} - -const Privacy: PrivacyComponent = () => { - // Define Privacy's behavior - return null; -}; - -Privacy.enableBlur = () => { - // Define the enableBlur behavior -}; - -Privacy.disableBlur = () => { - // Define the disableBlur behavior -}; - -export default Privacy; diff --git a/blue_modules/SettingsModule.ts b/blue_modules/SettingsModule.ts new file mode 100644 index 00000000000..0364e9affed --- /dev/null +++ b/blue_modules/SettingsModule.ts @@ -0,0 +1,53 @@ +import { Platform } from 'react-native'; +import NativeSettingsModule from '../codegen/NativeSettingsModule'; + +interface SettingsModuleInterface { + /** + * Initialize device UID if not exists + * Returns the device UID or "Disabled" if Do Not Track is enabled + */ + initializeDeviceUID(): Promise; + + /** + * Get the device UID + * Returns the device UID or "Disabled" if Do Not Track is enabled + */ + getDeviceUID(): Promise; + + /** + * Get the device UID copy (for Settings display) + */ + getDeviceUIDCopy(): Promise; + + /** + * Set the clearFilesOnLaunch preference + */ + setClearFilesOnLaunch(value: boolean): Promise; + + /** + * Get the clearFilesOnLaunch preference + */ + getClearFilesOnLaunch(): Promise; + + /** + * Set Do Not Track setting + */ + setDoNotTrack(enabled: boolean): Promise; + + /** + * Get Do Not Track setting + */ + getDoNotTrack(): Promise; + + /** + * Open the settings activity (Android only) + * This opens the app's settings screen + */ + openSettings(): Promise; +} + +// Only available on Android +const nativeModule = NativeSettingsModule ?? null; +const SettingsModule: SettingsModuleInterface | null = Platform.OS === 'android' ? nativeModule : null; + +export default SettingsModule; diff --git a/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControl.kt b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControl.kt new file mode 100644 index 00000000000..eb9ddc2521c --- /dev/null +++ b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControl.kt @@ -0,0 +1,263 @@ +package io.bluewallet.bluewallet.components.segmentedcontrol + +import android.content.Context +import android.content.res.ColorStateList +import android.graphics.Color +import android.util.AttributeSet +import android.widget.LinearLayout +import androidx.core.content.ContextCompat +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialButtonToggleGroup +import io.bluewallet.bluewallet.R + +class SegmentedControl @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val toggleGroup: MaterialButtonToggleGroup + private var currentSelectedIndex: Int = 0 + private var backgroundColorProp: Int? = null + private var tintColorProp: Int? = null + private var textColorProp: Int? = null + private var momentaryProp: Boolean = false + private var isEnabledProp: Boolean = true + + var values: Array = emptyArray() + set(value) { + field = value + updateSegments() + } + + var selectedIndex: Int = 0 + set(value) { + field = value + currentSelectedIndex = value + updateSelectedSegment() + } + + init { + orientation = HORIZONTAL + toggleGroup = MaterialButtonToggleGroup(context).apply { + isSingleSelection = true + isSelectionRequired = true + } + addView( + toggleGroup, + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ), + ) + + toggleGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (isChecked) { + val newIndex = findIndexById(checkedId) + if (newIndex != -1 && newIndex != currentSelectedIndex) { + currentSelectedIndex = newIndex + emitChangeEvent(newIndex) + if (momentaryProp) { + toggleGroup.clearChecked() + } + } + } + } + } + + private fun updateSegments() { + toggleGroup.removeAllViews() + + values.forEachIndexed { index, title -> + val button = MaterialButton( + context, + null, + com.google.android.material.R.attr.materialButtonOutlinedStyle, + ).apply { + text = title + id = generateViewId() + layoutParams = LinearLayout.LayoutParams( + 0, + LinearLayout.LayoutParams.WRAP_CONTENT, + 1f, + ) + isCheckable = true + + strokeWidth = 2 + applyEnabledState() + + val cornerRadius = resources.getDimensionPixelSize( + com.google.android.material.R.dimen.mtrl_btn_corner_radius, + ) + + when { + values.size == 1 -> { + this.cornerRadius = cornerRadius + } + index == 0 -> { + this.cornerRadius = cornerRadius + } + index == values.size - 1 -> { + this.cornerRadius = cornerRadius + } + else -> { + this.cornerRadius = 0 + } + } + } + + toggleGroup.addView(button) + } + + updateButtonColors() + updateSelectedSegment() + } + + private fun updateButtonColors() { + for (i in 0 until toggleGroup.childCount) { + val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue + + val selectedBgColor = tintColorProp ?: ContextCompat.getColor(context, R.color.button_background_color) + val unselectedBgColor = backgroundColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_background_color) + val resolvedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_text_color) + val selectedTextColor = resolvedTextColor + val unselectedTextColor = textColorProp ?: ContextCompat.getColor(context, R.color.button_disabled_text_color) + val borderColor = ContextCompat.getColor(context, R.color.form_border_color) + val rippleColor = ContextCompat.getColor(context, R.color.ripple_color) + val rippleColorSelected = ContextCompat.getColor(context, R.color.ripple_color_selected) + + val bgColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked), + ), + intArrayOf(selectedBgColor, unselectedBgColor), + ) + + val textColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked), + ), + intArrayOf(selectedTextColor, unselectedTextColor), + ) + + val strokeColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked), + ), + intArrayOf(borderColor, borderColor), + ) + + val rippleColorStateList = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked), + ), + intArrayOf(rippleColorSelected, rippleColor), + ) + + button.backgroundTintList = bgColorStateList + button.setTextColor(textColorStateList) + button.strokeColor = strokeColorStateList + button.rippleColor = rippleColorStateList + button.isEnabled = isEnabledProp + } + } + + fun setBackgroundColorProp(color: String?) { + backgroundColorProp = parseColor(color) + updateButtonColors() + } + + fun setTintColorProp(color: String?) { + tintColorProp = parseColor(color) + updateButtonColors() + } + + fun setTextColorProp(color: String?) { + textColorProp = parseColor(color) + updateButtonColors() + } + + fun setMomentaryProp(momentary: Boolean) { + momentaryProp = momentary + toggleGroup.isSelectionRequired = !momentary + } + + fun setEnabledProp(enabled: Boolean) { + isEnabledProp = enabled + toggleGroup.isEnabled = enabled + applyEnabledState() + } + + private fun updateSelectedSegment() { + if (values.isNotEmpty() && currentSelectedIndex in 0 until values.size) { + val buttonId = getButtonIdAtIndex(currentSelectedIndex) + if (buttonId != -1) { + toggleGroup.check(buttonId) + } + } + } + + private fun findIndexById(id: Int): Int { + for (i in 0 until toggleGroup.childCount) { + if (toggleGroup.getChildAt(i).id == id) { + return i + } + } + return -1 + } + + private fun getButtonIdAtIndex(index: Int): Int { + return if (index in 0 until toggleGroup.childCount) { + toggleGroup.getChildAt(index).id + } else { + -1 + } + } + + private fun emitChangeEvent(selectedIndex: Int) { + val reactContext = context as? ReactContext ?: return + val surfaceId = UIManagerHelper.getSurfaceId(reactContext) + val eventDispatcher = UIManagerHelper.getEventDispatcherForReactTag(reactContext, id) + + val event = Arguments.createMap().apply { + putInt("selectedIndex", selectedIndex) + } + + eventDispatcher?.dispatchEvent(ChangeEvent(surfaceId, id, event)) + } + + private fun applyEnabledState() { + for (i in 0 until toggleGroup.childCount) { + val button = toggleGroup.getChildAt(i) as? MaterialButton ?: continue + button.isEnabled = isEnabledProp + } + } + + private fun parseColor(color: String?): Int? { + return try { + color?.let { Color.parseColor(it) } + } catch (_: IllegalArgumentException) { + null + } + } + + private inner class ChangeEvent( + surfaceId: Int, + viewId: Int, + private val eventData: WritableMap, + ) : Event(surfaceId, viewId) { + + override fun getEventName(): String = "topChange" + + override fun getEventData(): WritableMap = eventData + } +} \ No newline at end of file diff --git a/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlManager.kt b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlManager.kt new file mode 100644 index 00000000000..6ee706bbb6e --- /dev/null +++ b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlManager.kt @@ -0,0 +1,67 @@ +package io.bluewallet.bluewallet.components.segmentedcontrol + +import com.facebook.react.bridge.ReadableArray +import com.facebook.react.common.MapBuilder +import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp + +@ReactModule(name = SegmentedControlManager.REACT_CLASS) +class SegmentedControlManager : SimpleViewManager() { + + companion object { + const val REACT_CLASS = "SegmentedControl" + } + + override fun getName(): String = REACT_CLASS + + override fun createViewInstance(reactContext: ThemedReactContext): SegmentedControl = + SegmentedControl(reactContext) + + @ReactProp(name = "values") + fun setValues(view: SegmentedControl, values: ReadableArray?) { + view.values = values?.let { arr -> Array(arr.size()) { arr.getString(it) ?: "" } } ?: emptyArray() + } + + @ReactProp(name = "selectedIndex", defaultInt = 0) + fun setSelectedIndex(view: SegmentedControl, selectedIndex: Int) { + view.selectedIndex = selectedIndex + } + + @ReactProp(name = "enabled", defaultBoolean = true) + fun setEnabled(view: SegmentedControl, enabled: Boolean) { + view.setEnabledProp(enabled) + } + + @ReactProp(name = "momentary", defaultBoolean = false) + fun setMomentary(view: SegmentedControl, momentary: Boolean) { + view.setMomentaryProp(momentary) + } + + @ReactProp(name = "backgroundColor") + fun setBackgroundColor(view: SegmentedControl, backgroundColor: String?) { + view.setBackgroundColorProp(backgroundColor) + } + + @ReactProp(name = "tintColor") + fun setTintColor(view: SegmentedControl, tintColor: String?) { + view.setTintColorProp(tintColor) + } + + @ReactProp(name = "textColor") + fun setTextColor(view: SegmentedControl, textColor: String?) { + view.setTextColorProp(textColor) + } + + override fun getExportedCustomBubblingEventTypeConstants(): Map? = + MapBuilder.builder() + .put( + "topChange", + MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onChange", "captured", "onChangeCapture"), + ), + ) + .build() +} \ No newline at end of file diff --git a/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlPackage.kt b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlPackage.kt new file mode 100644 index 00000000000..78b2ce12f1a --- /dev/null +++ b/blue_modules/Views/SegmentedControl/android/io/bluewallet/bluewallet/components/segmentedcontrol/SegmentedControlPackage.kt @@ -0,0 +1,17 @@ +package io.bluewallet.bluewallet.components.segmentedcontrol + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class SegmentedControlPackage : ReactPackage { + + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return emptyList() + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf(SegmentedControlManager()) + } +} \ No newline at end of file diff --git a/blue_modules/Views/SegmentedControl/ios/SegmentedControlBridge.m b/blue_modules/Views/SegmentedControl/ios/SegmentedControlBridge.m new file mode 100644 index 00000000000..5419c31733a --- /dev/null +++ b/blue_modules/Views/SegmentedControl/ios/SegmentedControlBridge.m @@ -0,0 +1,6 @@ +#import + +@interface RCT_EXTERN_MODULE(SegmentedControlManager, RCTViewManager) + +@end + diff --git a/blue_modules/Views/SegmentedControl/ios/SegmentedControlManager.swift b/blue_modules/Views/SegmentedControl/ios/SegmentedControlManager.swift new file mode 100644 index 00000000000..0d60d82bca8 --- /dev/null +++ b/blue_modules/Views/SegmentedControl/ios/SegmentedControlManager.swift @@ -0,0 +1,23 @@ +import Foundation +import UIKit +import React + +@objc(SegmentedControlManager) +final class SegmentedControlManager: RCTViewManager { + + override class func requiresMainQueueSetup() -> Bool { true } + + override func view() -> UIView! { + return SegmentedControlView() + } + + @objc class func propConfig_values() -> [String]! { ["NSArray"] } + @objc class func propConfig_selectedIndex() -> [String]! { ["NSInteger"] } + @objc class func propConfig_enabled() -> [String]! { ["BOOL"] } + @objc class func propConfig_momentary() -> [String]! { ["BOOL"] } + @objc class func propConfig_tintColor() -> [String]! { ["UIColor"] } + @objc class func propConfig_backgroundColor() -> [String]! { ["UIColor"] } + @objc class func propConfig_textColor() -> [String]! { ["UIColor"] } + @objc class func propConfig_onChange() -> [String]! { ["RCTBubblingEventBlock"] } +} + diff --git a/blue_modules/Views/SegmentedControl/ios/SegmentedControlView.swift b/blue_modules/Views/SegmentedControl/ios/SegmentedControlView.swift new file mode 100644 index 00000000000..3e8b6e2e9bb --- /dev/null +++ b/blue_modules/Views/SegmentedControl/ios/SegmentedControlView.swift @@ -0,0 +1,98 @@ +import UIKit +import React + +@objc(SegmentedControlView) +final class SegmentedControlView: UIView { + + private let segmentedControl = UISegmentedControl() + + // MARK: - Lifecycle + + override init(frame: CGRect) { + super.init(frame: frame) + setup() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setup() + } + + private func setup() { + segmentedControl.addTarget(self, action: #selector(handleValueChanged(_:)), for: .valueChanged) + addSubview(segmentedControl) + segmentedControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + segmentedControl.leadingAnchor.constraint(equalTo: leadingAnchor), + segmentedControl.trailingAnchor.constraint(equalTo: trailingAnchor), + segmentedControl.topAnchor.constraint(equalTo: topAnchor), + segmentedControl.bottomAnchor.constraint(equalTo: bottomAnchor), + ]) + } + + // MARK: - Prop setters + + @objc var values: NSArray = [] { + didSet { rebuildSegments() } + } + + @objc var selectedIndex: Int = 0 { + didSet { + guard segmentedControl.numberOfSegments > 0 else { return } + let clamped = min(max(selectedIndex, 0), segmentedControl.numberOfSegments - 1) + if segmentedControl.selectedSegmentIndex != clamped { + segmentedControl.selectedSegmentIndex = clamped + } + } + } + + @objc var enabled: Bool = true { + didSet { segmentedControl.isEnabled = enabled } + } + + @objc var momentary: Bool = false { + didSet { segmentedControl.isMomentary = momentary } + } + + @objc var textColor: UIColor? { + didSet { applyTextAttributes() } + } + + @objc var onChange: RCTBubblingEventBlock? + + override var tintColor: UIColor! { + didSet { segmentedControl.selectedSegmentTintColor = tintColor } + } + + override var backgroundColor: UIColor? { + didSet { segmentedControl.backgroundColor = backgroundColor } + } + + // MARK: - Private helpers + + private func rebuildSegments() { + let titles = values as? [String] ?? [] + segmentedControl.removeAllSegments() + for (i, title) in titles.enumerated() { + segmentedControl.insertSegment(withTitle: title, at: i, animated: false) + } + guard !titles.isEmpty else { return } + let clamped = min(max(selectedIndex, 0), titles.count - 1) + segmentedControl.selectedSegmentIndex = clamped + } + + private func applyTextAttributes() { + if let color = textColor { + segmentedControl.setTitleTextAttributes([.foregroundColor: color], for: .normal) + segmentedControl.setTitleTextAttributes([.foregroundColor: UIColor.white], for: .selected) + } else { + segmentedControl.setTitleTextAttributes(nil, for: .normal) + segmentedControl.setTitleTextAttributes(nil, for: .selected) + } + } + + @objc private func handleValueChanged(_ sender: UISegmentedControl) { + onChange?(["selectedIndex": sender.selectedSegmentIndex]) + } +} + diff --git a/blue_modules/WidgetCommunication.ios.js b/blue_modules/WidgetCommunication.ios.js deleted file mode 100644 index e926ec9648e..00000000000 --- a/blue_modules/WidgetCommunication.ios.js +++ /dev/null @@ -1,79 +0,0 @@ -import { useContext, useEffect } from 'react'; -import { BlueStorageContext } from './storage-context'; -import DefaultPreference from 'react-native-default-preference'; -import RNWidgetCenter from 'react-native-widget-center'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -function WidgetCommunication() { - WidgetCommunication.WidgetCommunicationAllWalletsSatoshiBalance = 'WidgetCommunicationAllWalletsSatoshiBalance'; - WidgetCommunication.WidgetCommunicationAllWalletsLatestTransactionTime = 'WidgetCommunicationAllWalletsLatestTransactionTime'; - WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed = 'WidgetCommunicationDisplayBalanceAllowed'; - WidgetCommunication.LatestTransactionIsUnconfirmed = 'WidgetCommunicationLatestTransactionIsUnconfirmed'; - const { wallets, walletsInitialized, isStorageEncrypted } = useContext(BlueStorageContext); - - WidgetCommunication.isBalanceDisplayAllowed = async () => { - try { - const displayBalance = JSON.parse(await AsyncStorage.getItem(WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed)); - if (displayBalance !== null) { - return displayBalance; - } else { - return true; - } - } catch (e) { - return true; - } - }; - - WidgetCommunication.setBalanceDisplayAllowed = async value => { - await AsyncStorage.setItem(WidgetCommunication.WidgetCommunicationDisplayBalanceAllowed, JSON.stringify(value)); - setValues(); - }; - - WidgetCommunication.reloadAllTimelines = () => { - RNWidgetCenter.reloadAllTimelines(); - }; - - const allWalletsBalanceAndTransactionTime = async () => { - if ((await isStorageEncrypted()) || !(await WidgetCommunication.isBalanceDisplayAllowed())) { - return { allWalletsBalance: 0, latestTransactionTime: 0 }; - } else { - let balance = 0; - let latestTransactionTime = 0; - for (const wallet of wallets) { - if (wallet.hideBalance) { - continue; - } - balance += wallet.getBalance(); - if (wallet.getLatestTransactionTimeEpoch() > latestTransactionTime) { - if (wallet.getTransactions()[0].confirmations === 0) { - latestTransactionTime = WidgetCommunication.LatestTransactionIsUnconfirmed; - } else { - latestTransactionTime = wallet.getLatestTransactionTimeEpoch(); - } - } - } - return { allWalletsBalance: balance, latestTransactionTime }; - } - }; - const setValues = async () => { - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - const { allWalletsBalance, latestTransactionTime } = await allWalletsBalanceAndTransactionTime(); - await DefaultPreference.set(WidgetCommunication.WidgetCommunicationAllWalletsSatoshiBalance, JSON.stringify(allWalletsBalance)); - await DefaultPreference.set( - WidgetCommunication.WidgetCommunicationAllWalletsLatestTransactionTime, - JSON.stringify(latestTransactionTime), - ); - RNWidgetCenter.reloadAllTimelines(); - }; - - useEffect(() => { - if (walletsInitialized) { - setValues(); - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallets, walletsInitialized]); - return null; -} - -export default WidgetCommunication; diff --git a/blue_modules/WidgetCommunication.js b/blue_modules/WidgetCommunication.js deleted file mode 100644 index 05cba00d80f..00000000000 --- a/blue_modules/WidgetCommunication.js +++ /dev/null @@ -1,8 +0,0 @@ -function WidgetCommunication(props) { - WidgetCommunication.isBalanceDisplayAllowed = () => {}; - WidgetCommunication.setBalanceDisplayAllowed = () => {}; - WidgetCommunication.reloadAllTimelines = () => {}; - return null; -} - -export default WidgetCommunication; diff --git a/blue_modules/aezeed/README.md b/blue_modules/aezeed/README.md deleted file mode 100644 index e8a32cf3cbb..00000000000 --- a/blue_modules/aezeed/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# aezeed -A package for encoding, decoding, and generating mnemonics of the aezeed specification. (WIP) diff --git a/blue_modules/aezeed/package.json b/blue_modules/aezeed/package.json deleted file mode 100644 index d92abf6c23b..00000000000 --- a/blue_modules/aezeed/package.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "_from": "aezeed", - "_id": "aezeed@0.0.4", - "_inBundle": false, - "_integrity": "sha512-KAv2y2AtbqpdtsabCLE+C0G0h4BZLeMHsLCRga3VicYLxD17RflUBJ++c5qdpN6B6fkvK90r6bWg52Z/gMC7gQ==", - "_location": "/aezeed", - "_phantomChildren": {}, - "_requested": { - "type": "tag", - "registry": true, - "raw": "aezeed", - "name": "aezeed", - "escapedName": "aezeed", - "rawSpec": "", - "saveSpec": null, - "fetchSpec": "latest" - }, - "_requiredBy": [ - "#USER", - "/" - ], - "_resolved": "https://registry.npmjs.org/aezeed/-/aezeed-0.0.4.tgz", - "_shasum": "8fce8778d34f5566328f61df7706351cb15873a9", - "_spec": "aezeed", - "_where": "/home/overtorment/Documents/BlueWallet", - "author": { - "name": "Jonathan Underwood" - }, - "bugs": { - "url": "https://github.com/bitcoinjs/aezeed/issues" - }, - "bundleDependencies": false, - "dependencies": { - "aez": "^1.0.1", - "crc-32": "npm:junderw-crc32c@^1.2.0", - "randombytes": "^2.1.0", - "scryptsy": "^2.1.0" - }, - "deprecated": false, - "description": "A package for encoding, decoding, and generating mnemonics of the aezeed specification.", - "devDependencies": { - "@types/jest": "^26.0.10", - "@types/node": "^16.0.0", - "@types/randombytes": "^2.0.0", - "@types/scryptsy": "^2.0.0", - "jest": "^26.4.2", - "prettier": "^2.1.0", - "ts-jest": "^26.2.0", - "tslint": "^6.1.3", - "typescript": "^4.0.2" - }, - "files": [ - "src" - ], - "homepage": "https://github.com/bitcoinjs/aezeed#readme", - "keywords": [ - "aezeed", - "bitcoin", - "lightning", - "lnd" - ], - "license": "MIT", - "main": "src/cipherseed.js", - "name": "aezeed", - "repository": { - "type": "git", - "url": "git+https://github.com/bitcoinjs/aezeed.git" - }, - "scripts": { - "build": "npm run clean && tsc -p tsconfig.json", - "clean": "rm -rf src", - "coverage": "npm run unit -- --coverage", - "format": "npm run prettier -- --write", - "format:ci": "npm run prettier -- --check", - "gitdiff": "git diff --exit-code", - "gitdiff:ci": "npm run build && npm run gitdiff", - "lint": "tslint -p tsconfig.json -c tslint.json", - "prepublishOnly": "npm run test && npm run gitdiff", - "prettier": "prettier 'ts_src/**/*.ts' --single-quote --trailing-comma=all --ignore-path ./.prettierignore", - "test": "npm run build && npm run format:ci && npm run lint && npm run unit", - "unit": "jest --config=jest.json --runInBand" - }, - "types": "src/cipherseed.d.ts", - "version": "0.0.4" -} diff --git a/blue_modules/aezeed/src/cipherseed.d.ts b/blue_modules/aezeed/src/cipherseed.d.ts deleted file mode 100644 index c664c3beb93..00000000000 --- a/blue_modules/aezeed/src/cipherseed.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -/// -export declare class CipherSeed { - entropy: Buffer; - salt: Buffer; - internalVersion: number; - birthday: number; - private static decipher; - static fromMnemonic(mnemonic: string, password?: string): CipherSeed; - static random(): CipherSeed; - static changePassword(mnemonic: string, oldPassword: string | null, newPassword: string): string; - constructor(entropy: Buffer, salt: Buffer, internalVersion?: number, birthday?: number); - get birthDate(): Date; - toMnemonic(password?: string, cipherSeedVersion?: number): string; - private encipher; -} diff --git a/blue_modules/aezeed/src/cipherseed.js b/blue_modules/aezeed/src/cipherseed.js deleted file mode 100644 index 586972dffc0..00000000000 --- a/blue_modules/aezeed/src/cipherseed.js +++ /dev/null @@ -1,105 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.CipherSeed = void 0; -const BlueCrypto = require('react-native-blue-crypto'); -const scrypt = require("scryptsy"); -const rng = require("randombytes"); -const mn = require("./mnemonic"); -const params_1 = require("./params"); -const aez = require('aez'); -const crc = require('junderw-crc32c'); -const BITCOIN_GENESIS = new Date('2009-01-03T18:15:05.000Z').getTime(); -const daysSinceGenesis = (time) => Math.floor((time.getTime() - BITCOIN_GENESIS) / params_1.ONE_DAY); - -async function scryptWrapper(secret, salt, N, r, p, dkLen, progressCallback) { - if (BlueCrypto.isAvailable()) { - secret = Buffer.from(secret).toString('hex'); - salt = Buffer.from(salt).toString('hex'); - const hex = await BlueCrypto.scrypt(secret, salt, N, r, p, dkLen); - return Buffer.from(hex, 'hex'); - } else { - // fallback to js implementation - return scrypt(secret, salt, N, r, p, dkLen, progressCallback); - } -} - -class CipherSeed { - constructor(entropy, salt, internalVersion = 0, birthday = daysSinceGenesis(new Date())) { - this.entropy = entropy; - this.salt = salt; - this.internalVersion = internalVersion; - this.birthday = birthday; - if (entropy && entropy.length !== 16) - throw new Error('incorrect entropy length'); - if (salt && salt.length !== 5) - throw new Error('incorrect salt length'); - } - - static async decipher(cipherBuf, password) { - if (cipherBuf[0] >= params_1.PARAMS.length) { - throw new Error('Invalid cipherSeedVersion'); - } - const cipherSeedVersion = cipherBuf[0]; - const params = params_1.PARAMS[cipherSeedVersion]; - const checksum = Buffer.allocUnsafe(4); - const checksumNum = crc.buf(cipherBuf.slice(0, 29)); - checksum.writeInt32BE(checksumNum); - if (!checksum.equals(cipherBuf.slice(29))) { - throw new Error('CRC checksum mismatch'); - } - const salt = cipherBuf.slice(24, 29); - const key = await scryptWrapper(Buffer.from(password, 'utf8'), salt, params.n, params.r, params.p, 32); - const adBytes = Buffer.allocUnsafe(6); - adBytes.writeUInt8(cipherSeedVersion, 0); - salt.copy(adBytes, 1); - const plainText = aez.decrypt(key, null, [adBytes], 4, cipherBuf.slice(1, 24)); - if (plainText === null) - throw new Error('Invalid Password'); - return new CipherSeed(plainText.slice(3, 19), salt, plainText[0], plainText.readUInt16BE(1)); - } - - static async fromMnemonic(mnemonic, password = params_1.DEFAULT_PASSWORD) { - const bytes = mn.mnemonicToBytes(mnemonic); - return await CipherSeed.decipher(bytes, password); - } - - static random() { - return new CipherSeed(rng(16), rng(5)); - } - - static async changePassword(mnemonic, oldPassword, newPassword) { - const pwd = oldPassword === null ? params_1.DEFAULT_PASSWORD : oldPassword; - const cs = await CipherSeed.fromMnemonic(mnemonic, pwd); - return await cs.toMnemonic(newPassword); - } - - get birthDate() { - return new Date(BITCOIN_GENESIS + this.birthday * params_1.ONE_DAY); - } - - async toMnemonic(password = params_1.DEFAULT_PASSWORD, cipherSeedVersion = params_1.CIPHER_SEED_VERSION) { - return mn.mnemonicFromBytes(await this.encipher(password, cipherSeedVersion)); - } - - async encipher(password, cipherSeedVersion) { - const pwBuf = Buffer.from(password, 'utf8'); - const params = params_1.PARAMS[cipherSeedVersion]; - const key = await scryptWrapper(pwBuf, this.salt, params.n, params.r, params.p, 32); - const seedBytes = Buffer.allocUnsafe(19); - seedBytes.writeUInt8(this.internalVersion, 0); - seedBytes.writeUInt16BE(this.birthday, 1); - this.entropy.copy(seedBytes, 3); - const adBytes = Buffer.allocUnsafe(6); - adBytes.writeUInt8(cipherSeedVersion, 0); - this.salt.copy(adBytes, 1); - const cipherText = aez.encrypt(key, null, [adBytes], 4, seedBytes); - const cipherSeedBytes = Buffer.allocUnsafe(33); - cipherSeedBytes.writeUInt8(cipherSeedVersion, 0); - cipherText.copy(cipherSeedBytes, 1); - this.salt.copy(cipherSeedBytes, 24); - const checksumNum = crc.buf(cipherSeedBytes.slice(0, 29)); - cipherSeedBytes.writeInt32BE(checksumNum, 29); - return cipherSeedBytes; - } -} -exports.CipherSeed = CipherSeed; diff --git a/blue_modules/aezeed/src/mnemonic.d.ts b/blue_modules/aezeed/src/mnemonic.d.ts deleted file mode 100644 index 78a0a7d982b..00000000000 --- a/blue_modules/aezeed/src/mnemonic.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -/// -export declare function mnemonicFromBytes(bytes: Buffer): string; -export declare function mnemonicToBytes(mnemonic: string): Buffer; diff --git a/blue_modules/aezeed/src/mnemonic.js b/blue_modules/aezeed/src/mnemonic.js deleted file mode 100644 index e0415bb3f6a..00000000000 --- a/blue_modules/aezeed/src/mnemonic.js +++ /dev/null @@ -1,2091 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.mnemonicToBytes = exports.mnemonicFromBytes = void 0; -function mnemonicFromBytes(bytes) { - const bits = bytesToBinary(Array.from(bytes)); - const chunks = bits.match(/(.{1,11})/g); - const words = chunks.map((binary) => { - const index = binaryToByte(binary); - return WORDLIST[index]; - }); - return words.join(' '); -} -exports.mnemonicFromBytes = mnemonicFromBytes; -function mnemonicToBytes(mnemonic) { - const INVALID = 'Invalid Mnemonic'; - const words = mnemonic.split(' '); - if (words.length !== 24) - throw new Error(INVALID); - const bits = words - .map((word) => { - const index = WORDLIST.indexOf(word); - if (index === -1) - throw new Error(INVALID); - return lpad(index.toString(2), '0', 11); - }) - .join(''); - const entropyBytes = bits.match(/(.{8})/g).map(binaryToByte); - return Buffer.from(entropyBytes); -} -exports.mnemonicToBytes = mnemonicToBytes; -function bytesToBinary(bytes) { - return bytes.map((x) => lpad(x.toString(2), '0', 8)).join(''); -} -function binaryToByte(bin) { - return parseInt(bin, 2); -} -function lpad(str, padString, length) { - while (str.length < length) - str = padString + str; - return str; -} -const WORDLIST = [ - 'abandon', - 'ability', - 'able', - 'about', - 'above', - 'absent', - 'absorb', - 'abstract', - 'absurd', - 'abuse', - 'access', - 'accident', - 'account', - 'accuse', - 'achieve', - 'acid', - 'acoustic', - 'acquire', - 'across', - 'act', - 'action', - 'actor', - 'actress', - 'actual', - 'adapt', - 'add', - 'addict', - 'address', - 'adjust', - 'admit', - 'adult', - 'advance', - 'advice', - 'aerobic', - 'affair', - 'afford', - 'afraid', - 'again', - 'age', - 'agent', - 'agree', - 'ahead', - 'aim', - 'air', - 'airport', - 'aisle', - 'alarm', - 'album', - 'alcohol', - 'alert', - 'alien', - 'all', - 'alley', - 'allow', - 'almost', - 'alone', - 'alpha', - 'already', - 'also', - 'alter', - 'always', - 'amateur', - 'amazing', - 'among', - 'amount', - 'amused', - 'analyst', - 'anchor', - 'ancient', - 'anger', - 'angle', - 'angry', - 'animal', - 'ankle', - 'announce', - 'annual', - 'another', - 'answer', - 'antenna', - 'antique', - 'anxiety', - 'any', - 'apart', - 'apology', - 'appear', - 'apple', - 'approve', - 'april', - 'arch', - 'arctic', - 'area', - 'arena', - 'argue', - 'arm', - 'armed', - 'armor', - 'army', - 'around', - 'arrange', - 'arrest', - 'arrive', - 'arrow', - 'art', - 'artefact', - 'artist', - 'artwork', - 'ask', - 'aspect', - 'assault', - 'asset', - 'assist', - 'assume', - 'asthma', - 'athlete', - 'atom', - 'attack', - 'attend', - 'attitude', - 'attract', - 'auction', - 'audit', - 'august', - 'aunt', - 'author', - 'auto', - 'autumn', - 'average', - 'avocado', - 'avoid', - 'awake', - 'aware', - 'away', - 'awesome', - 'awful', - 'awkward', - 'axis', - 'baby', - 'bachelor', - 'bacon', - 'badge', - 'bag', - 'balance', - 'balcony', - 'ball', - 'bamboo', - 'banana', - 'banner', - 'bar', - 'barely', - 'bargain', - 'barrel', - 'base', - 'basic', - 'basket', - 'battle', - 'beach', - 'bean', - 'beauty', - 'because', - 'become', - 'beef', - 'before', - 'begin', - 'behave', - 'behind', - 'believe', - 'below', - 'belt', - 'bench', - 'benefit', - 'best', - 'betray', - 'better', - 'between', - 'beyond', - 'bicycle', - 'bid', - 'bike', - 'bind', - 'biology', - 'bird', - 'birth', - 'bitter', - 'black', - 'blade', - 'blame', - 'blanket', - 'blast', - 'bleak', - 'bless', - 'blind', - 'blood', - 'blossom', - 'blouse', - 'blue', - 'blur', - 'blush', - 'board', - 'boat', - 'body', - 'boil', - 'bomb', - 'bone', - 'bonus', - 'book', - 'boost', - 'border', - 'boring', - 'borrow', - 'boss', - 'bottom', - 'bounce', - 'box', - 'boy', - 'bracket', - 'brain', - 'brand', - 'brass', - 'brave', - 'bread', - 'breeze', - 'brick', - 'bridge', - 'brief', - 'bright', - 'bring', - 'brisk', - 'broccoli', - 'broken', - 'bronze', - 'broom', - 'brother', - 'brown', - 'brush', - 'bubble', - 'buddy', - 'budget', - 'buffalo', - 'build', - 'bulb', - 'bulk', - 'bullet', - 'bundle', - 'bunker', - 'burden', - 'burger', - 'burst', - 'bus', - 'business', - 'busy', - 'butter', - 'buyer', - 'buzz', - 'cabbage', - 'cabin', - 'cable', - 'cactus', - 'cage', - 'cake', - 'call', - 'calm', - 'camera', - 'camp', - 'can', - 'canal', - 'cancel', - 'candy', - 'cannon', - 'canoe', - 'canvas', - 'canyon', - 'capable', - 'capital', - 'captain', - 'car', - 'carbon', - 'card', - 'cargo', - 'carpet', - 'carry', - 'cart', - 'case', - 'cash', - 'casino', - 'castle', - 'casual', - 'cat', - 'catalog', - 'catch', - 'category', - 'cattle', - 'caught', - 'cause', - 'caution', - 'cave', - 'ceiling', - 'celery', - 'cement', - 'census', - 'century', - 'cereal', - 'certain', - 'chair', - 'chalk', - 'champion', - 'change', - 'chaos', - 'chapter', - 'charge', - 'chase', - 'chat', - 'cheap', - 'check', - 'cheese', - 'chef', - 'cherry', - 'chest', - 'chicken', - 'chief', - 'child', - 'chimney', - 'choice', - 'choose', - 'chronic', - 'chuckle', - 'chunk', - 'churn', - 'cigar', - 'cinnamon', - 'circle', - 'citizen', - 'city', - 'civil', - 'claim', - 'clap', - 'clarify', - 'claw', - 'clay', - 'clean', - 'clerk', - 'clever', - 'click', - 'client', - 'cliff', - 'climb', - 'clinic', - 'clip', - 'clock', - 'clog', - 'close', - 'cloth', - 'cloud', - 'clown', - 'club', - 'clump', - 'cluster', - 'clutch', - 'coach', - 'coast', - 'coconut', - 'code', - 'coffee', - 'coil', - 'coin', - 'collect', - 'color', - 'column', - 'combine', - 'come', - 'comfort', - 'comic', - 'common', - 'company', - 'concert', - 'conduct', - 'confirm', - 'congress', - 'connect', - 'consider', - 'control', - 'convince', - 'cook', - 'cool', - 'copper', - 'copy', - 'coral', - 'core', - 'corn', - 'correct', - 'cost', - 'cotton', - 'couch', - 'country', - 'couple', - 'course', - 'cousin', - 'cover', - 'coyote', - 'crack', - 'cradle', - 'craft', - 'cram', - 'crane', - 'crash', - 'crater', - 'crawl', - 'crazy', - 'cream', - 'credit', - 'creek', - 'crew', - 'cricket', - 'crime', - 'crisp', - 'critic', - 'crop', - 'cross', - 'crouch', - 'crowd', - 'crucial', - 'cruel', - 'cruise', - 'crumble', - 'crunch', - 'crush', - 'cry', - 'crystal', - 'cube', - 'culture', - 'cup', - 'cupboard', - 'curious', - 'current', - 'curtain', - 'curve', - 'cushion', - 'custom', - 'cute', - 'cycle', - 'dad', - 'damage', - 'damp', - 'dance', - 'danger', - 'daring', - 'dash', - 'daughter', - 'dawn', - 'day', - 'deal', - 'debate', - 'debris', - 'decade', - 'december', - 'decide', - 'decline', - 'decorate', - 'decrease', - 'deer', - 'defense', - 'define', - 'defy', - 'degree', - 'delay', - 'deliver', - 'demand', - 'demise', - 'denial', - 'dentist', - 'deny', - 'depart', - 'depend', - 'deposit', - 'depth', - 'deputy', - 'derive', - 'describe', - 'desert', - 'design', - 'desk', - 'despair', - 'destroy', - 'detail', - 'detect', - 'develop', - 'device', - 'devote', - 'diagram', - 'dial', - 'diamond', - 'diary', - 'dice', - 'diesel', - 'diet', - 'differ', - 'digital', - 'dignity', - 'dilemma', - 'dinner', - 'dinosaur', - 'direct', - 'dirt', - 'disagree', - 'discover', - 'disease', - 'dish', - 'dismiss', - 'disorder', - 'display', - 'distance', - 'divert', - 'divide', - 'divorce', - 'dizzy', - 'doctor', - 'document', - 'dog', - 'doll', - 'dolphin', - 'domain', - 'donate', - 'donkey', - 'donor', - 'door', - 'dose', - 'double', - 'dove', - 'draft', - 'dragon', - 'drama', - 'drastic', - 'draw', - 'dream', - 'dress', - 'drift', - 'drill', - 'drink', - 'drip', - 'drive', - 'drop', - 'drum', - 'dry', - 'duck', - 'dumb', - 'dune', - 'during', - 'dust', - 'dutch', - 'duty', - 'dwarf', - 'dynamic', - 'eager', - 'eagle', - 'early', - 'earn', - 'earth', - 'easily', - 'east', - 'easy', - 'echo', - 'ecology', - 'economy', - 'edge', - 'edit', - 'educate', - 'effort', - 'egg', - 'eight', - 'either', - 'elbow', - 'elder', - 'electric', - 'elegant', - 'element', - 'elephant', - 'elevator', - 'elite', - 'else', - 'embark', - 'embody', - 'embrace', - 'emerge', - 'emotion', - 'employ', - 'empower', - 'empty', - 'enable', - 'enact', - 'end', - 'endless', - 'endorse', - 'enemy', - 'energy', - 'enforce', - 'engage', - 'engine', - 'enhance', - 'enjoy', - 'enlist', - 'enough', - 'enrich', - 'enroll', - 'ensure', - 'enter', - 'entire', - 'entry', - 'envelope', - 'episode', - 'equal', - 'equip', - 'era', - 'erase', - 'erode', - 'erosion', - 'error', - 'erupt', - 'escape', - 'essay', - 'essence', - 'estate', - 'eternal', - 'ethics', - 'evidence', - 'evil', - 'evoke', - 'evolve', - 'exact', - 'example', - 'excess', - 'exchange', - 'excite', - 'exclude', - 'excuse', - 'execute', - 'exercise', - 'exhaust', - 'exhibit', - 'exile', - 'exist', - 'exit', - 'exotic', - 'expand', - 'expect', - 'expire', - 'explain', - 'expose', - 'express', - 'extend', - 'extra', - 'eye', - 'eyebrow', - 'fabric', - 'face', - 'faculty', - 'fade', - 'faint', - 'faith', - 'fall', - 'false', - 'fame', - 'family', - 'famous', - 'fan', - 'fancy', - 'fantasy', - 'farm', - 'fashion', - 'fat', - 'fatal', - 'father', - 'fatigue', - 'fault', - 'favorite', - 'feature', - 'february', - 'federal', - 'fee', - 'feed', - 'feel', - 'female', - 'fence', - 'festival', - 'fetch', - 'fever', - 'few', - 'fiber', - 'fiction', - 'field', - 'figure', - 'file', - 'film', - 'filter', - 'final', - 'find', - 'fine', - 'finger', - 'finish', - 'fire', - 'firm', - 'first', - 'fiscal', - 'fish', - 'fit', - 'fitness', - 'fix', - 'flag', - 'flame', - 'flash', - 'flat', - 'flavor', - 'flee', - 'flight', - 'flip', - 'float', - 'flock', - 'floor', - 'flower', - 'fluid', - 'flush', - 'fly', - 'foam', - 'focus', - 'fog', - 'foil', - 'fold', - 'follow', - 'food', - 'foot', - 'force', - 'forest', - 'forget', - 'fork', - 'fortune', - 'forum', - 'forward', - 'fossil', - 'foster', - 'found', - 'fox', - 'fragile', - 'frame', - 'frequent', - 'fresh', - 'friend', - 'fringe', - 'frog', - 'front', - 'frost', - 'frown', - 'frozen', - 'fruit', - 'fuel', - 'fun', - 'funny', - 'furnace', - 'fury', - 'future', - 'gadget', - 'gain', - 'galaxy', - 'gallery', - 'game', - 'gap', - 'garage', - 'garbage', - 'garden', - 'garlic', - 'garment', - 'gas', - 'gasp', - 'gate', - 'gather', - 'gauge', - 'gaze', - 'general', - 'genius', - 'genre', - 'gentle', - 'genuine', - 'gesture', - 'ghost', - 'giant', - 'gift', - 'giggle', - 'ginger', - 'giraffe', - 'girl', - 'give', - 'glad', - 'glance', - 'glare', - 'glass', - 'glide', - 'glimpse', - 'globe', - 'gloom', - 'glory', - 'glove', - 'glow', - 'glue', - 'goat', - 'goddess', - 'gold', - 'good', - 'goose', - 'gorilla', - 'gospel', - 'gossip', - 'govern', - 'gown', - 'grab', - 'grace', - 'grain', - 'grant', - 'grape', - 'grass', - 'gravity', - 'great', - 'green', - 'grid', - 'grief', - 'grit', - 'grocery', - 'group', - 'grow', - 'grunt', - 'guard', - 'guess', - 'guide', - 'guilt', - 'guitar', - 'gun', - 'gym', - 'habit', - 'hair', - 'half', - 'hammer', - 'hamster', - 'hand', - 'happy', - 'harbor', - 'hard', - 'harsh', - 'harvest', - 'hat', - 'have', - 'hawk', - 'hazard', - 'head', - 'health', - 'heart', - 'heavy', - 'hedgehog', - 'height', - 'hello', - 'helmet', - 'help', - 'hen', - 'hero', - 'hidden', - 'high', - 'hill', - 'hint', - 'hip', - 'hire', - 'history', - 'hobby', - 'hockey', - 'hold', - 'hole', - 'holiday', - 'hollow', - 'home', - 'honey', - 'hood', - 'hope', - 'horn', - 'horror', - 'horse', - 'hospital', - 'host', - 'hotel', - 'hour', - 'hover', - 'hub', - 'huge', - 'human', - 'humble', - 'humor', - 'hundred', - 'hungry', - 'hunt', - 'hurdle', - 'hurry', - 'hurt', - 'husband', - 'hybrid', - 'ice', - 'icon', - 'idea', - 'identify', - 'idle', - 'ignore', - 'ill', - 'illegal', - 'illness', - 'image', - 'imitate', - 'immense', - 'immune', - 'impact', - 'impose', - 'improve', - 'impulse', - 'inch', - 'include', - 'income', - 'increase', - 'index', - 'indicate', - 'indoor', - 'industry', - 'infant', - 'inflict', - 'inform', - 'inhale', - 'inherit', - 'initial', - 'inject', - 'injury', - 'inmate', - 'inner', - 'innocent', - 'input', - 'inquiry', - 'insane', - 'insect', - 'inside', - 'inspire', - 'install', - 'intact', - 'interest', - 'into', - 'invest', - 'invite', - 'involve', - 'iron', - 'island', - 'isolate', - 'issue', - 'item', - 'ivory', - 'jacket', - 'jaguar', - 'jar', - 'jazz', - 'jealous', - 'jeans', - 'jelly', - 'jewel', - 'job', - 'join', - 'joke', - 'journey', - 'joy', - 'judge', - 'juice', - 'jump', - 'jungle', - 'junior', - 'junk', - 'just', - 'kangaroo', - 'keen', - 'keep', - 'ketchup', - 'key', - 'kick', - 'kid', - 'kidney', - 'kind', - 'kingdom', - 'kiss', - 'kit', - 'kitchen', - 'kite', - 'kitten', - 'kiwi', - 'knee', - 'knife', - 'knock', - 'know', - 'lab', - 'label', - 'labor', - 'ladder', - 'lady', - 'lake', - 'lamp', - 'language', - 'laptop', - 'large', - 'later', - 'latin', - 'laugh', - 'laundry', - 'lava', - 'law', - 'lawn', - 'lawsuit', - 'layer', - 'lazy', - 'leader', - 'leaf', - 'learn', - 'leave', - 'lecture', - 'left', - 'leg', - 'legal', - 'legend', - 'leisure', - 'lemon', - 'lend', - 'length', - 'lens', - 'leopard', - 'lesson', - 'letter', - 'level', - 'liar', - 'liberty', - 'library', - 'license', - 'life', - 'lift', - 'light', - 'like', - 'limb', - 'limit', - 'link', - 'lion', - 'liquid', - 'list', - 'little', - 'live', - 'lizard', - 'load', - 'loan', - 'lobster', - 'local', - 'lock', - 'logic', - 'lonely', - 'long', - 'loop', - 'lottery', - 'loud', - 'lounge', - 'love', - 'loyal', - 'lucky', - 'luggage', - 'lumber', - 'lunar', - 'lunch', - 'luxury', - 'lyrics', - 'machine', - 'mad', - 'magic', - 'magnet', - 'maid', - 'mail', - 'main', - 'major', - 'make', - 'mammal', - 'man', - 'manage', - 'mandate', - 'mango', - 'mansion', - 'manual', - 'maple', - 'marble', - 'march', - 'margin', - 'marine', - 'market', - 'marriage', - 'mask', - 'mass', - 'master', - 'match', - 'material', - 'math', - 'matrix', - 'matter', - 'maximum', - 'maze', - 'meadow', - 'mean', - 'measure', - 'meat', - 'mechanic', - 'medal', - 'media', - 'melody', - 'melt', - 'member', - 'memory', - 'mention', - 'menu', - 'mercy', - 'merge', - 'merit', - 'merry', - 'mesh', - 'message', - 'metal', - 'method', - 'middle', - 'midnight', - 'milk', - 'million', - 'mimic', - 'mind', - 'minimum', - 'minor', - 'minute', - 'miracle', - 'mirror', - 'misery', - 'miss', - 'mistake', - 'mix', - 'mixed', - 'mixture', - 'mobile', - 'model', - 'modify', - 'mom', - 'moment', - 'monitor', - 'monkey', - 'monster', - 'month', - 'moon', - 'moral', - 'more', - 'morning', - 'mosquito', - 'mother', - 'motion', - 'motor', - 'mountain', - 'mouse', - 'move', - 'movie', - 'much', - 'muffin', - 'mule', - 'multiply', - 'muscle', - 'museum', - 'mushroom', - 'music', - 'must', - 'mutual', - 'myself', - 'mystery', - 'myth', - 'naive', - 'name', - 'napkin', - 'narrow', - 'nasty', - 'nation', - 'nature', - 'near', - 'neck', - 'need', - 'negative', - 'neglect', - 'neither', - 'nephew', - 'nerve', - 'nest', - 'net', - 'network', - 'neutral', - 'never', - 'news', - 'next', - 'nice', - 'night', - 'noble', - 'noise', - 'nominee', - 'noodle', - 'normal', - 'north', - 'nose', - 'notable', - 'note', - 'nothing', - 'notice', - 'novel', - 'now', - 'nuclear', - 'number', - 'nurse', - 'nut', - 'oak', - 'obey', - 'object', - 'oblige', - 'obscure', - 'observe', - 'obtain', - 'obvious', - 'occur', - 'ocean', - 'october', - 'odor', - 'off', - 'offer', - 'office', - 'often', - 'oil', - 'okay', - 'old', - 'olive', - 'olympic', - 'omit', - 'once', - 'one', - 'onion', - 'online', - 'only', - 'open', - 'opera', - 'opinion', - 'oppose', - 'option', - 'orange', - 'orbit', - 'orchard', - 'order', - 'ordinary', - 'organ', - 'orient', - 'original', - 'orphan', - 'ostrich', - 'other', - 'outdoor', - 'outer', - 'output', - 'outside', - 'oval', - 'oven', - 'over', - 'own', - 'owner', - 'oxygen', - 'oyster', - 'ozone', - 'pact', - 'paddle', - 'page', - 'pair', - 'palace', - 'palm', - 'panda', - 'panel', - 'panic', - 'panther', - 'paper', - 'parade', - 'parent', - 'park', - 'parrot', - 'party', - 'pass', - 'patch', - 'path', - 'patient', - 'patrol', - 'pattern', - 'pause', - 'pave', - 'payment', - 'peace', - 'peanut', - 'pear', - 'peasant', - 'pelican', - 'pen', - 'penalty', - 'pencil', - 'people', - 'pepper', - 'perfect', - 'permit', - 'person', - 'pet', - 'phone', - 'photo', - 'phrase', - 'physical', - 'piano', - 'picnic', - 'picture', - 'piece', - 'pig', - 'pigeon', - 'pill', - 'pilot', - 'pink', - 'pioneer', - 'pipe', - 'pistol', - 'pitch', - 'pizza', - 'place', - 'planet', - 'plastic', - 'plate', - 'play', - 'please', - 'pledge', - 'pluck', - 'plug', - 'plunge', - 'poem', - 'poet', - 'point', - 'polar', - 'pole', - 'police', - 'pond', - 'pony', - 'pool', - 'popular', - 'portion', - 'position', - 'possible', - 'post', - 'potato', - 'pottery', - 'poverty', - 'powder', - 'power', - 'practice', - 'praise', - 'predict', - 'prefer', - 'prepare', - 'present', - 'pretty', - 'prevent', - 'price', - 'pride', - 'primary', - 'print', - 'priority', - 'prison', - 'private', - 'prize', - 'problem', - 'process', - 'produce', - 'profit', - 'program', - 'project', - 'promote', - 'proof', - 'property', - 'prosper', - 'protect', - 'proud', - 'provide', - 'public', - 'pudding', - 'pull', - 'pulp', - 'pulse', - 'pumpkin', - 'punch', - 'pupil', - 'puppy', - 'purchase', - 'purity', - 'purpose', - 'purse', - 'push', - 'put', - 'puzzle', - 'pyramid', - 'quality', - 'quantum', - 'quarter', - 'question', - 'quick', - 'quit', - 'quiz', - 'quote', - 'rabbit', - 'raccoon', - 'race', - 'rack', - 'radar', - 'radio', - 'rail', - 'rain', - 'raise', - 'rally', - 'ramp', - 'ranch', - 'random', - 'range', - 'rapid', - 'rare', - 'rate', - 'rather', - 'raven', - 'raw', - 'razor', - 'ready', - 'real', - 'reason', - 'rebel', - 'rebuild', - 'recall', - 'receive', - 'recipe', - 'record', - 'recycle', - 'reduce', - 'reflect', - 'reform', - 'refuse', - 'region', - 'regret', - 'regular', - 'reject', - 'relax', - 'release', - 'relief', - 'rely', - 'remain', - 'remember', - 'remind', - 'remove', - 'render', - 'renew', - 'rent', - 'reopen', - 'repair', - 'repeat', - 'replace', - 'report', - 'require', - 'rescue', - 'resemble', - 'resist', - 'resource', - 'response', - 'result', - 'retire', - 'retreat', - 'return', - 'reunion', - 'reveal', - 'review', - 'reward', - 'rhythm', - 'rib', - 'ribbon', - 'rice', - 'rich', - 'ride', - 'ridge', - 'rifle', - 'right', - 'rigid', - 'ring', - 'riot', - 'ripple', - 'risk', - 'ritual', - 'rival', - 'river', - 'road', - 'roast', - 'robot', - 'robust', - 'rocket', - 'romance', - 'roof', - 'rookie', - 'room', - 'rose', - 'rotate', - 'rough', - 'round', - 'route', - 'royal', - 'rubber', - 'rude', - 'rug', - 'rule', - 'run', - 'runway', - 'rural', - 'sad', - 'saddle', - 'sadness', - 'safe', - 'sail', - 'salad', - 'salmon', - 'salon', - 'salt', - 'salute', - 'same', - 'sample', - 'sand', - 'satisfy', - 'satoshi', - 'sauce', - 'sausage', - 'save', - 'say', - 'scale', - 'scan', - 'scare', - 'scatter', - 'scene', - 'scheme', - 'school', - 'science', - 'scissors', - 'scorpion', - 'scout', - 'scrap', - 'screen', - 'script', - 'scrub', - 'sea', - 'search', - 'season', - 'seat', - 'second', - 'secret', - 'section', - 'security', - 'seed', - 'seek', - 'segment', - 'select', - 'sell', - 'seminar', - 'senior', - 'sense', - 'sentence', - 'series', - 'service', - 'session', - 'settle', - 'setup', - 'seven', - 'shadow', - 'shaft', - 'shallow', - 'share', - 'shed', - 'shell', - 'sheriff', - 'shield', - 'shift', - 'shine', - 'ship', - 'shiver', - 'shock', - 'shoe', - 'shoot', - 'shop', - 'short', - 'shoulder', - 'shove', - 'shrimp', - 'shrug', - 'shuffle', - 'shy', - 'sibling', - 'sick', - 'side', - 'siege', - 'sight', - 'sign', - 'silent', - 'silk', - 'silly', - 'silver', - 'similar', - 'simple', - 'since', - 'sing', - 'siren', - 'sister', - 'situate', - 'six', - 'size', - 'skate', - 'sketch', - 'ski', - 'skill', - 'skin', - 'skirt', - 'skull', - 'slab', - 'slam', - 'sleep', - 'slender', - 'slice', - 'slide', - 'slight', - 'slim', - 'slogan', - 'slot', - 'slow', - 'slush', - 'small', - 'smart', - 'smile', - 'smoke', - 'smooth', - 'snack', - 'snake', - 'snap', - 'sniff', - 'snow', - 'soap', - 'soccer', - 'social', - 'sock', - 'soda', - 'soft', - 'solar', - 'soldier', - 'solid', - 'solution', - 'solve', - 'someone', - 'song', - 'soon', - 'sorry', - 'sort', - 'soul', - 'sound', - 'soup', - 'source', - 'south', - 'space', - 'spare', - 'spatial', - 'spawn', - 'speak', - 'special', - 'speed', - 'spell', - 'spend', - 'sphere', - 'spice', - 'spider', - 'spike', - 'spin', - 'spirit', - 'split', - 'spoil', - 'sponsor', - 'spoon', - 'sport', - 'spot', - 'spray', - 'spread', - 'spring', - 'spy', - 'square', - 'squeeze', - 'squirrel', - 'stable', - 'stadium', - 'staff', - 'stage', - 'stairs', - 'stamp', - 'stand', - 'start', - 'state', - 'stay', - 'steak', - 'steel', - 'stem', - 'step', - 'stereo', - 'stick', - 'still', - 'sting', - 'stock', - 'stomach', - 'stone', - 'stool', - 'story', - 'stove', - 'strategy', - 'street', - 'strike', - 'strong', - 'struggle', - 'student', - 'stuff', - 'stumble', - 'style', - 'subject', - 'submit', - 'subway', - 'success', - 'such', - 'sudden', - 'suffer', - 'sugar', - 'suggest', - 'suit', - 'summer', - 'sun', - 'sunny', - 'sunset', - 'super', - 'supply', - 'supreme', - 'sure', - 'surface', - 'surge', - 'surprise', - 'surround', - 'survey', - 'suspect', - 'sustain', - 'swallow', - 'swamp', - 'swap', - 'swarm', - 'swear', - 'sweet', - 'swift', - 'swim', - 'swing', - 'switch', - 'sword', - 'symbol', - 'symptom', - 'syrup', - 'system', - 'table', - 'tackle', - 'tag', - 'tail', - 'talent', - 'talk', - 'tank', - 'tape', - 'target', - 'task', - 'taste', - 'tattoo', - 'taxi', - 'teach', - 'team', - 'tell', - 'ten', - 'tenant', - 'tennis', - 'tent', - 'term', - 'test', - 'text', - 'thank', - 'that', - 'theme', - 'then', - 'theory', - 'there', - 'they', - 'thing', - 'this', - 'thought', - 'three', - 'thrive', - 'throw', - 'thumb', - 'thunder', - 'ticket', - 'tide', - 'tiger', - 'tilt', - 'timber', - 'time', - 'tiny', - 'tip', - 'tired', - 'tissue', - 'title', - 'toast', - 'tobacco', - 'today', - 'toddler', - 'toe', - 'together', - 'toilet', - 'token', - 'tomato', - 'tomorrow', - 'tone', - 'tongue', - 'tonight', - 'tool', - 'tooth', - 'top', - 'topic', - 'topple', - 'torch', - 'tornado', - 'tortoise', - 'toss', - 'total', - 'tourist', - 'toward', - 'tower', - 'town', - 'toy', - 'track', - 'trade', - 'traffic', - 'tragic', - 'train', - 'transfer', - 'trap', - 'trash', - 'travel', - 'tray', - 'treat', - 'tree', - 'trend', - 'trial', - 'tribe', - 'trick', - 'trigger', - 'trim', - 'trip', - 'trophy', - 'trouble', - 'truck', - 'true', - 'truly', - 'trumpet', - 'trust', - 'truth', - 'try', - 'tube', - 'tuition', - 'tumble', - 'tuna', - 'tunnel', - 'turkey', - 'turn', - 'turtle', - 'twelve', - 'twenty', - 'twice', - 'twin', - 'twist', - 'two', - 'type', - 'typical', - 'ugly', - 'umbrella', - 'unable', - 'unaware', - 'uncle', - 'uncover', - 'under', - 'undo', - 'unfair', - 'unfold', - 'unhappy', - 'uniform', - 'unique', - 'unit', - 'universe', - 'unknown', - 'unlock', - 'until', - 'unusual', - 'unveil', - 'update', - 'upgrade', - 'uphold', - 'upon', - 'upper', - 'upset', - 'urban', - 'urge', - 'usage', - 'use', - 'used', - 'useful', - 'useless', - 'usual', - 'utility', - 'vacant', - 'vacuum', - 'vague', - 'valid', - 'valley', - 'valve', - 'van', - 'vanish', - 'vapor', - 'various', - 'vast', - 'vault', - 'vehicle', - 'velvet', - 'vendor', - 'venture', - 'venue', - 'verb', - 'verify', - 'version', - 'very', - 'vessel', - 'veteran', - 'viable', - 'vibrant', - 'vicious', - 'victory', - 'video', - 'view', - 'village', - 'vintage', - 'violin', - 'virtual', - 'virus', - 'visa', - 'visit', - 'visual', - 'vital', - 'vivid', - 'vocal', - 'voice', - 'void', - 'volcano', - 'volume', - 'vote', - 'voyage', - 'wage', - 'wagon', - 'wait', - 'walk', - 'wall', - 'walnut', - 'want', - 'warfare', - 'warm', - 'warrior', - 'wash', - 'wasp', - 'waste', - 'water', - 'wave', - 'way', - 'wealth', - 'weapon', - 'wear', - 'weasel', - 'weather', - 'web', - 'wedding', - 'weekend', - 'weird', - 'welcome', - 'west', - 'wet', - 'whale', - 'what', - 'wheat', - 'wheel', - 'when', - 'where', - 'whip', - 'whisper', - 'wide', - 'width', - 'wife', - 'wild', - 'will', - 'win', - 'window', - 'wine', - 'wing', - 'wink', - 'winner', - 'winter', - 'wire', - 'wisdom', - 'wise', - 'wish', - 'witness', - 'wolf', - 'woman', - 'wonder', - 'wood', - 'wool', - 'word', - 'work', - 'world', - 'worry', - 'worth', - 'wrap', - 'wreck', - 'wrestle', - 'wrist', - 'write', - 'wrong', - 'yard', - 'year', - 'yellow', - 'you', - 'young', - 'youth', - 'zebra', - 'zero', - 'zone', - 'zoo', -]; diff --git a/blue_modules/aezeed/src/params.d.ts b/blue_modules/aezeed/src/params.d.ts deleted file mode 100644 index 9f7aefcaddf..00000000000 --- a/blue_modules/aezeed/src/params.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -export declare const PARAMS: { - n: number; - r: number; - p: number; -}[]; -export declare const DEFAULT_PASSWORD = "aezeed"; -export declare const CIPHER_SEED_VERSION = 0; -export declare const ONE_DAY: number; diff --git a/blue_modules/aezeed/src/params.js b/blue_modules/aezeed/src/params.js deleted file mode 100644 index b7eca893f8a..00000000000 --- a/blue_modules/aezeed/src/params.js +++ /dev/null @@ -1,14 +0,0 @@ -"use strict"; -Object.defineProperty(exports, "__esModule", { value: true }); -exports.ONE_DAY = exports.CIPHER_SEED_VERSION = exports.DEFAULT_PASSWORD = exports.PARAMS = void 0; -exports.PARAMS = [ - { - // version 0 - n: 32768, - r: 8, - p: 1, - }, -]; -exports.DEFAULT_PASSWORD = 'aezeed'; -exports.CIPHER_SEED_VERSION = 0; -exports.ONE_DAY = 24 * 60 * 60 * 1000; diff --git a/blue_modules/analytics.js b/blue_modules/analytics.js deleted file mode 100644 index 0cd2ae61b95..00000000000 --- a/blue_modules/analytics.js +++ /dev/null @@ -1,44 +0,0 @@ -import { getUniqueId } from 'react-native-device-info'; -import Bugsnag from '@bugsnag/react-native'; -const BlueApp = require('../BlueApp'); - -let userHasOptedOut = false; - -if (process.env.NODE_ENV !== 'development') { - Bugsnag.start({ - collectUserIp: false, - user: { - id: getUniqueId(), - }, - onError: function (event) { - return !userHasOptedOut; - }, - }); -} - -BlueApp.isDoNotTrackEnabled().then(value => { - if (value) userHasOptedOut = true; -}); - -const A = async event => {}; - -A.ENUM = { - INIT: 'INIT', - GOT_NONZERO_BALANCE: 'GOT_NONZERO_BALANCE', - GOT_ZERO_BALANCE: 'GOT_ZERO_BALANCE', - CREATED_WALLET: 'CREATED_WALLET', - CREATED_LIGHTNING_WALLET: 'CREATED_LIGHTNING_WALLET', - APP_UNSUSPENDED: 'APP_UNSUSPENDED', - NAVIGATED_TO_WALLETS_HODLHODL: 'NAVIGATED_TO_WALLETS_HODLHODL', -}; - -A.setOptOut = value => { - if (value) userHasOptedOut = true; -}; - -A.logError = errorString => { - console.error(errorString); - Bugsnag.notify(new Error(String(errorString))); -}; - -module.exports = A; diff --git a/blue_modules/analytics.ts b/blue_modules/analytics.ts new file mode 100644 index 00000000000..91e4ce9e5f6 --- /dev/null +++ b/blue_modules/analytics.ts @@ -0,0 +1,53 @@ +import Bugsnag from '@bugsnag/react-native'; +import { getUniqueId } from 'react-native-device-info'; + +import { BlueApp as BlueAppClass } from '../class/blue-app'; + +const BlueApp = BlueAppClass.getInstance(); + +/** + * in case Bugsnag was started, but user decided to opt out while using the app, we have this + * flag `userHasOptedOut` and we forbid logging in `onError` handler + * @type {boolean} + */ +let userHasOptedOut: boolean = false; + +(async () => { + try { + // Don't try to start Bugsnag again as it's already initialized in native code. + // Configure it only when tracking is allowed. + const doNotTrack = await BlueApp.isDoNotTrackEnabled(); + if (doNotTrack) { + userHasOptedOut = true; + return; + } + + const uniqueID = await getUniqueId(); + Bugsnag.setUser(uniqueID); + Bugsnag.addOnError(function () { + return !userHasOptedOut; + }); + } catch (error) { + // Never let analytics setup crash the app. + console.error('Failed to initialize analytics:', error); + } +})(); + +const A = async (event: string) => {}; + +A.setOptOut = (value: boolean) => { + if (value) userHasOptedOut = true; +}; + +A.logError = (errorString: string) => { + console.error(errorString); + if (!userHasOptedOut) { + try { + Bugsnag.notify(new Error(String(errorString))); + } catch (error) { + console.error('Failed to report error to Bugsnag:', error); + } + } +}; + +export default A; diff --git a/blue_modules/arkade-adapters/realm/notificationSuppressionRepository.ts b/blue_modules/arkade-adapters/realm/notificationSuppressionRepository.ts new file mode 100644 index 00000000000..952195054e0 --- /dev/null +++ b/blue_modules/arkade-adapters/realm/notificationSuppressionRepository.ts @@ -0,0 +1,71 @@ +// Per-wallet Realm storage for notification-suppression entries. +// +// Lives inside the per-wallet Arkade Realm so suppression state is +// bucket-scoped, encrypted by the wallet's existing Realm key, and removed +// automatically when the wallet is deleted (deleteArkadeRealm tears down the +// whole file). Avoids leaking a stable per-wallet handle into a global +// AsyncStorage key. + +export type ArkSwapNotificationAction = 'claim' | 'refund'; + +// Realm schema. `realm` is a peer dependency we don't import here directly; +// the schema is a plain object consumed by realmInstance.ts via the schemas +// array. Pattern matches BoltzSwapSchema in @arkade-os/boltz-swap. +export const ArkSwapNotificationSuppressionSchema = { + name: 'ArkSwapNotificationSuppression', + primaryKey: 'id', + properties: { + id: 'string', + swapId: 'string', + action: 'string', + postedAt: 'int', + }, +}; + +const compositeId = (swapId: string, action: ArkSwapNotificationAction): string => `${swapId}:${action}`; + +interface ArkSwapNotificationSuppressionRow { + id: string; + swapId: string; + action: ArkSwapNotificationAction; + postedAt: number; +} + +export class RealmNotificationSuppressionRepository { + private readonly realm: any; + + constructor(realm: any) { + this.realm = realm; + } + + has(swapId: string, action: ArkSwapNotificationAction): boolean { + const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action)); + return Boolean(row); + } + + record(swapId: string, action: ArkSwapNotificationAction): void { + this.realm.write(() => { + const row: ArkSwapNotificationSuppressionRow = { + id: compositeId(swapId, action), + swapId, + action, + postedAt: Date.now(), + }; + this.realm.create('ArkSwapNotificationSuppression', row, 'modified'); + }); + } + + clearForSwap(swapId: string): void { + this.realm.write(() => { + const matches = this.realm.objects('ArkSwapNotificationSuppression').filtered('swapId == $0', swapId); + this.realm.delete(matches); + }); + } + + clearForSwapAction(swapId: string, action: ArkSwapNotificationAction): void { + this.realm.write(() => { + const row = this.realm.objectForPrimaryKey('ArkSwapNotificationSuppression', compositeId(swapId, action)); + if (row) this.realm.delete(row); + }); + } +} diff --git a/blue_modules/arkade-adapters/realm/realmInstance.ts b/blue_modules/arkade-adapters/realm/realmInstance.ts new file mode 100644 index 00000000000..9a42d4ebc6c --- /dev/null +++ b/blue_modules/arkade-adapters/realm/realmInstance.ts @@ -0,0 +1,197 @@ +import RNFS from 'react-native-fs'; +import Realm from 'realm'; +import Keychain, { ACCESSIBLE, SECURITY_LEVEL } from 'react-native-keychain'; + +import { ArkRealmSchemas, ARK_REALM_SCHEMA_VERSION, runArkRealmMigrations } from '@arkade-os/sdk/repositories/realm'; +import { BoltzRealmSchemas } from '@arkade-os/boltz-swap/repositories/realm'; +import { randomBytes } from '../../../class/rng'; +import { uint8ArrayToHex, hexToUint8Array } from '../../uint8array-extras'; +import { ArkSwapNotificationSuppressionSchema } from './notificationSuppressionRepository'; + +const AllArkadeSchemas = [...ArkRealmSchemas, ...BoltzRealmSchemas, ArkSwapNotificationSuppressionSchema]; + +// App-owned schemas added on top of the SDK's. Bump when an app-owned schema +// changes; SDK bumps are handled by ARK_REALM_SCHEMA_VERSION. Realm requires +// a strictly increasing schemaVersion when objects are added; computing +// `SDK + offset` keeps the local additions ahead of any future SDK bump. +const LOCAL_ARK_SCHEMA_OFFSET = 1; +const ARKADE_REALM_SCHEMA_VERSION = ARK_REALM_SCHEMA_VERSION + LOCAL_ARK_SCHEMA_OFFSET; + +const realmInstances: Map = new Map(); +const openInFlight: Map> = new Map(); + +// Files live in a dedicated subdirectory so BlueApp.moveRealmFilesToCacheDirectory() +// — which sweeps top-level *.realm files from Documents into the OS-purgeable cache +// — never sees them. RNFS.readDir is non-recursive, so the subdirectory is invisible +// to that scan. Ark Realm holds non-recoverable swap/claim data and must stay in +// Documents. +const arkadeDir = (): string => `${RNFS.DocumentDirectoryPath}/arkade`; +const realmPathFor = (namespace: string): string => `${arkadeDir()}/arkade-${namespace}.realm`; +const keychainServiceFor = (namespace: string): string => `arkade_realm_${namespace}`; + +async function ensureArkadeDir(): Promise { + const dir = arkadeDir(); + if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir); +} + +async function loadOrCreateEncryptionKey(namespace: string): Promise { + const service = keychainServiceFor(namespace); + + const credentials = await Keychain.getGenericPassword({ service }); + if (credentials) return hexToUint8Array(credentials.password); + + const buf = await randomBytes(64); + const password = uint8ArrayToHex(buf); + + // Accessibility: match the rest of the app's secret accessibility. RNSecureKeyStore + // in class/blue-app.ts and hooks/useBiometrics.ts both use WHEN_UNLOCKED_THIS_DEVICE_ONLY; + // the default of AFTER_FIRST_UNLOCK would expose the Realm key while the device is locked. + // + // Security level: preflight via getSecurityLevel() rather than try/catch around + // SECURE_HARDWARE. getSecurityLevel returns null on iOS (where the option is moot) + // and the highest supported level on Android. We only opt into SECURE_HARDWARE when + // the device actually backs it; otherwise let react-native-keychain pick its default. + // Catching every setGenericPassword error and silently retrying with ANY (the previous + // shape) downgrades on unrelated failures — preflight surfaces those instead. + const supportedLevel = await Keychain.getSecurityLevel(); + const opts: Parameters[2] = { + service, + accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }; + if (supportedLevel === SECURITY_LEVEL.SECURE_HARDWARE) { + opts.securityLevel = SECURITY_LEVEL.SECURE_HARDWARE; + } + await Keychain.setGenericPassword(service, password, opts); + + return hexToUint8Array(password); +} + +/** + * Returns a per-wallet Realm instance keyed by `namespace`. Each Ark wallet + * gets its own encrypted Realm file and its own Keychain entry so wallets + * never collide on WalletState/contracts/swaps and storage buckets stay + * isolated. + * + * Concurrent callers for the same namespace receive the same in-flight + * promise. Errors are surfaced to the caller; the in-flight entry is cleared + * so a later retry can succeed. + */ +export async function getArkadeRealm(namespace: string): Promise { + const cached = realmInstances.get(namespace); + if (cached && !cached.isClosed) return cached; + if (cached && cached.isClosed) realmInstances.delete(namespace); + + const inFlight = openInFlight.get(namespace); + if (inFlight) return inFlight; + + const opening = (async () => { + await ensureArkadeDir(); + + const encryptionKey = await loadOrCreateEncryptionKey(namespace); + + const realm = await Realm.open({ + schema: AllArkadeSchemas as unknown as Realm.ObjectSchema[], + schemaVersion: ARKADE_REALM_SCHEMA_VERSION, + onMigration: (oldRealm, newRealm) => { + runArkRealmMigrations(oldRealm, newRealm); + }, + path: realmPathFor(namespace), + encryptionKey, + excludeFromIcloudBackup: true, + }); + + realmInstances.set(namespace, realm); + return realm; + })(); + + openInFlight.set(namespace, opening); + try { + return await opening; + } finally { + openInFlight.delete(namespace); + } +} + +/** + * Close the cached Realm for `namespace`, if any. The file and Keychain + * entry are preserved. + */ +export function closeArkadeRealm(namespace: string): void { + const realm = realmInstances.get(namespace); + if (realm && !realm.isClosed) { + realm.removeAllListeners(); + realm.close(); + } + realmInstances.delete(namespace); +} + +/** + * Close every cached Arkade Realm instance. Used on app shutdown / sign out. + */ +export function closeAllArkadeRealms(): void { + for (const ns of Array.from(realmInstances.keys())) { + closeArkadeRealm(ns); + } +} + +/** + * Delete the Realm file and the Keychain entry for `namespace`. Used when + * an Ark wallet is removed. Failures are logged but do not throw — leaving + * an orphan file or Keychain entry is preferable to crashing the app's + * delete path. Ark Realm failures stay scoped to the Ark wallet path. + * + * The Keychain encryption key is reset only when the Realm file is gone + * (or never existed). Resetting the key while the encrypted file remains + * would leave the user unable to open the orphan on a future re-import: + * a fresh random key would be generated and the old file's ciphertext + * could not be decrypted. + */ +export async function deleteArkadeRealm(namespace: string): Promise { + closeArkadeRealm(namespace); + + const path = realmPathFor(namespace); + let realmRemoved = false; + try { + // Realm.deleteFile is sync and removes the .realm + .lock + .management + // siblings in one call. It is forgiving when the file does not exist + // (no-op), but we guard via Realm.exists to keep behavior explicit. + if (Realm.exists(path)) { + Realm.deleteFile({ path }); + } + realmRemoved = true; + } catch (e: any) { + console.log(`[ArkadeRealm] Realm.deleteFile failed for ${path}:`, e?.message ?? e); + } + + // Best-effort sweep of any sibling files Realm.deleteFile might have left + // behind. These are not load-bearing for re-import; failures are tolerated. + for (const suffix of ['.note']) { + const sibling = `${path}${suffix}`; + try { + if (await RNFS.exists(sibling)) await RNFS.unlink(sibling); + } catch (e: any) { + console.log(`[ArkadeRealm] failed to delete ${sibling}:`, e?.message ?? e); + } + } + + if (!realmRemoved) { + console.log( + `[ArkadeRealm] keeping encryption key for ${namespace} because Realm file cleanup failed; key preserved so a future delete retry can still decrypt the orphan`, + ); + return; + } + + try { + await Keychain.resetGenericPassword({ service: keychainServiceFor(namespace) }); + } catch (e: any) { + console.log(`[ArkadeRealm] failed to reset keychain for ${namespace}:`, e?.message ?? e); + } +} + +// Exported for tests only. +export const __testing__ = { + realmInstances, + openInFlight, + realmPathFor, + keychainServiceFor, +}; diff --git a/blue_modules/arkade-background.ts b/blue_modules/arkade-background.ts new file mode 100644 index 00000000000..8468b841fe2 --- /dev/null +++ b/blue_modules/arkade-background.ts @@ -0,0 +1,423 @@ +// Background task module for Ark swap monitoring. +// +// Responsibilities: +// - Passive monitoring: poll Boltz swap status for non-terminal swaps in +// every Ark wallet's per-wallet Realm and persist remote changes through +// the SDK update helpers. +// - Post a local notification when an SDK predicate flags a swap as +// claimable/refundable. No claim, refund, recover, or signing happens in +// background — those remain foreground-only. +// +// State here is in-process: it survives configure→fetch→fetch ticks within a +// single JS runtime but is gone after process kill. Realm remains the +// durable source of truth for swap status and notification suppression. +import BackgroundFetch from 'react-native-background-fetch'; + +import { + BoltzSwapProvider, + isChainFinalStatus, + isReverseFinalStatus, + isSubmarineFinalStatus, + updateChainSwapStatus, + updateReverseSwapStatus, + updateSubmarineSwapStatus, +} from '@arkade-os/boltz-swap'; +import type { BoltzChainSwap, BoltzReverseSwap, BoltzSubmarineSwap, BoltzSwap } from '@arkade-os/boltz-swap'; +import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm'; + +import { BlueApp as BlueAppClass } from '../class/blue-app'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import { getArkadeRealm } from './arkade-adapters/realm/realmInstance'; +import { + RealmNotificationSuppressionRepository, + type ArkSwapNotificationAction, +} from './arkade-adapters/realm/notificationSuppressionRepository'; +import { notifyArkSwapActionable, resolveActionableAction } from './arkade-notifications'; + +const BlueApp = BlueAppClass.getInstance(); + +// Single shared provider. The constructor only stores config; it does not +// open sockets. Re-using one instance avoids per-poll allocation. +const swapProvider = new BoltzSwapProvider({ network: 'bitcoin' }); +const DEFAULT_MAX_RUN_MS = 25_000; +let maxRunMs = DEFAULT_MAX_RUN_MS; + +interface ArkTaskState { + lastRegisteredAt: number | null; + lastUnregisteredAt: number | null; + lastRunStartedAt: number | null; + lastRunFinishedAt: number | null; + walletsScanned: number; + swapsPolled: number; + swapsUpdated: number; + lastError: string | null; + exitedDueToUnavailableStorage: boolean; + availability: 'unknown' | 'available' | 'denied' | 'restricted'; + // Set whenever swapsUpdated is incremented. Used by reconcile() to detect + // updates that crossed run boundaries (per-run swapsUpdated is reset). + lastSwapUpdateAt: number; + lastReconciledAt: number; +} + +const state: ArkTaskState = { + lastRegisteredAt: null, + lastUnregisteredAt: null, + lastRunStartedAt: null, + lastRunFinishedAt: null, + walletsScanned: 0, + swapsPolled: 0, + swapsUpdated: 0, + lastError: null, + exitedDueToUnavailableStorage: false, + availability: 'unknown', + lastSwapUpdateAt: 0, + lastReconciledAt: 0, +}; + +// Per-wallet last-seen status cache. Outer key: wallet namespace; inner key: +// swap ID; value: last status this background module observed. Diagnostic + +// reconciliation hint only — Realm is durable. +const swapStatusCache: Map> = new Map(); + +// Per-poll last-seen actionable action keyed by `${namespace}:${swapId}`. +// Used to detect predicate flips (true → false or claim ↔ refund) so we can +// clear the corresponding Realm suppression row even when the swap status +// has not yet reached a terminal state. In-process only; cleared by +// stopArkBackgroundTask so a later run does not falsely diagnose a flip on +// the first poll after restart. +const lastSeenActionMap: Map = new Map(); + +let configured = false; +let running = false; +let cancelRequested = false; +let runDeadline: number | null = null; + +export function getArkTaskState(): Readonly { + return Object.freeze({ ...state }); +} + +function recordError(message: string): void { + state.lastError = message; +} + +function shouldStopRun(): boolean { + return cancelRequested || (runDeadline !== null && Date.now() >= runDeadline); +} + +function remainingRunMs(): number { + if (runDeadline === null) return maxRunMs; + return Math.max(runDeadline - Date.now(), 0); +} + +async function withTimeout(promise: Promise, ms: number): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('deadline exceeded')), ms); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function isFinalStatus(swap: BoltzSwap): boolean { + switch (swap.type) { + case 'reverse': + return isReverseFinalStatus(swap.status); + case 'submarine': + return isSubmarineFinalStatus(swap.status); + case 'chain': + return isChainFinalStatus(swap.status); + } +} + +async function persistStatusChange(swap: BoltzSwap, newStatus: BoltzSwap['status'], repo: RealmSwapRepository): Promise { + if (swap.type === 'reverse') { + await updateReverseSwapStatus(swap as BoltzReverseSwap, newStatus, s => repo.saveSwap(s)); + } else if (swap.type === 'submarine') { + await updateSubmarineSwapStatus(swap as BoltzSubmarineSwap, newStatus, s => repo.saveSwap(s)); + } else { + await updateChainSwapStatus(swap as BoltzChainSwap, newStatus, s => repo.saveSwap(s)); + } +} + +async function pollSwap( + swap: BoltzSwap, + namespace: string, + repo: RealmSwapRepository, + suppression: RealmNotificationSuppressionRepository, + walletID: string, + walletLabel: string, +): Promise { + if (shouldStopRun()) return; + + state.swapsPolled += 1; + let response; + try { + response = await withTimeout(swapProvider.getSwapStatus(swap.id), remainingRunMs()); + } catch (e: any) { + recordError(`getSwapStatus(${swap.id}): ${e?.message ?? e}`); + if (e?.message === 'deadline exceeded' || remainingRunMs() <= 0) cancelRequested = true; + return; + } + + if (shouldStopRun()) return; + + const remoteStatus = response.status; + const statusChanged = remoteStatus !== swap.status; + // The SDK update helpers (updateReverseSwapStatus etc.) save a copy and do + // not mutate `swap`, so any post-persist predicate or terminal check on + // `swap` would read the pre-update status. effectiveSwap carries the + // status we want subsequent checks to evaluate against. + const effectiveSwap: BoltzSwap = statusChanged ? ({ ...swap, status: remoteStatus } as BoltzSwap) : swap; + + if (statusChanged) { + try { + await persistStatusChange(swap, remoteStatus, repo); + } catch (e: any) { + recordError(`persistStatusChange(${swap.id}): ${e?.message ?? e}`); + return; + } + + state.swapsUpdated += 1; + state.lastSwapUpdateAt = Date.now(); + let perWallet = swapStatusCache.get(namespace); + if (!perWallet) { + perWallet = new Map(); + swapStatusCache.set(namespace, perWallet); + } + perWallet.set(swap.id, remoteStatus); + } + + // Actionable evaluation runs on every non-terminal poll, NOT only after a + // status change. Otherwise a swap that became actionable in a previous run + // but never received a successful post (notify failed mid-run, OS-level + // drop, permission-denied skip, app cold-started with already-actionable + // Realm state) would never be re-checked because subsequent polls observe + // remoteStatus === swap.status and would otherwise exit. The Realm + // suppression repo is the dedup layer. + const lastKey = `${namespace}:${effectiveSwap.id}`; + if (isFinalStatus(effectiveSwap)) { + try { + suppression.clearForSwap(effectiveSwap.id); + } catch (e: any) { + recordError(`suppression.clearForSwap(${effectiveSwap.id}): ${e?.message ?? e}`); + } + lastSeenActionMap.delete(lastKey); + return; + } + + const action = resolveActionableAction(effectiveSwap); + const lastSeen = lastSeenActionMap.get(lastKey); + if (lastSeen && lastSeen !== action) { + // Predicate flipped out of `lastSeen` (either to null or to the other + // action). Clear the stale suppression so the next observed flip back + // re-fires. + try { + suppression.clearForSwapAction(effectiveSwap.id, lastSeen); + } catch (e: any) { + recordError(`suppression.clearForSwapAction(${effectiveSwap.id}): ${e?.message ?? e}`); + } + } + + if (action) { + try { + await notifyArkSwapActionable(effectiveSwap, suppression, walletID, walletLabel); + } catch (e: any) { + recordError(`notifyArkSwapActionable(${effectiveSwap.id}): ${e?.message ?? e}`); + } + lastSeenActionMap.set(lastKey, action); + } else { + lastSeenActionMap.delete(lastKey); + } +} + +async function processWallet(wallet: LightningArkWallet): Promise { + state.walletsScanned += 1; + const namespace = wallet.getNamespace(); + const walletID = wallet.getID(); + const walletLabel = wallet.getLabel(); + + let realm; + try { + realm = await getArkadeRealm(namespace); + } catch (e: any) { + // Most likely the Keychain is locked (WHEN_UNLOCKED_THIS_DEVICE_ONLY) or + // the Realm file is unreachable. Either way the background task no-ops + // for this wallet — claim/refund is foreground-only anyway. + state.exitedDueToUnavailableStorage = true; + recordError(`getArkadeRealm(${namespace}): ${e?.message ?? e}`); + return; + } + + let swaps: BoltzSwap[]; + const repo = new RealmSwapRepository(realm as any); + const suppression = new RealmNotificationSuppressionRepository(realm); + try { + swaps = await repo.getAllSwaps(); + } catch (e: any) { + recordError(`getAllSwaps(${namespace}): ${e?.message ?? e}`); + return; + } + + for (const swap of swaps) { + if (isFinalStatus(swap)) continue; + if (shouldStopRun()) return; + await pollSwap(swap, namespace, repo, suppression, walletID, walletLabel); + } +} + +export async function runArkBackgroundTask(taskId: string): Promise { + if (running) { + BackgroundFetch.finish(taskId); + return; + } + + running = true; + cancelRequested = false; + runDeadline = Date.now() + maxRunMs; + state.lastRunStartedAt = Date.now(); + state.walletsScanned = 0; + state.swapsPolled = 0; + state.swapsUpdated = 0; + state.exitedDueToUnavailableStorage = false; + + try { + const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet); + if (wallets.length === 0) return; + + for (const wallet of wallets) { + if (shouldStopRun()) break; + try { + await processWallet(wallet); + } catch (e: any) { + recordError(`processWallet: ${e?.message ?? e}`); + } + } + } finally { + state.lastRunFinishedAt = Date.now(); + runDeadline = null; + cancelRequested = false; + running = false; + BackgroundFetch.finish(taskId); + } +} + +export function onArkBackgroundTaskTimeout(taskId: string): void { + cancelRequested = true; + state.lastError = 'timeout'; + state.lastRunFinishedAt = Date.now(); + BackgroundFetch.finish(taskId); +} + +function availabilityFromStatus(status: number): ArkTaskState['availability'] { + if (status === BackgroundFetch.STATUS_AVAILABLE) return 'available'; + if (status === BackgroundFetch.STATUS_DENIED) return 'denied'; + if (status === BackgroundFetch.STATUS_RESTRICTED) return 'restricted'; + return 'unknown'; +} + +export async function registerArkBackgroundTask(): Promise { + if (configured) { + await BackgroundFetch.start(); + state.lastRegisteredAt = Date.now(); + return; + } + + const config: Parameters[0] = { + minimumFetchInterval: 15, + stopOnTerminate: false, + startOnBoot: true, + enableHeadless: true, + requiredNetworkType: BackgroundFetch.NETWORK_TYPE_ANY, + }; + + try { + const status = await BackgroundFetch.configure(config, runArkBackgroundTask, onArkBackgroundTaskTimeout); + state.availability = availabilityFromStatus(status); + if (state.availability === 'available') { + configured = true; + state.lastRegisteredAt = Date.now(); + } else { + console.warn(`[ArkBackground] Background fetch unavailable: ${state.availability}`); + } + } catch (e: any) { + recordError(`configure: ${e?.message ?? e}`); + } +} + +export async function stopArkBackgroundTask(): Promise { + cancelRequested = true; + try { + await BackgroundFetch.stop(); + } catch (e: any) { + recordError(`stop: ${e?.message ?? e}`); + } + + // Await in-flight run completion (draining). A live background run keeps + // Detox's FabricTimersIdlingResource busy and disconnects the JS bridge. + const start = Date.now(); + // eslint-disable-next-line no-unmodified-loop-condition + while (running && Date.now() - start < 30_000) { + await new Promise(resolve => setTimeout(resolve, 50)); + } + + swapStatusCache.clear(); + // Clear in-process predicate-flip tracker so a later run does not + // diagnose a flip on the first poll after restart. Persistent suppression + // (Realm) is intentionally untouched — re-registering must keep history. + lastSeenActionMap.clear(); + state.lastUnregisteredAt = Date.now(); +} + +export function reconcileArkBackgroundTaskResults(triggerRefreshForWallet: (walletId: string) => void): void { + if (state.lastSwapUpdateAt <= state.lastReconciledAt) return; + + const wallets = BlueApp.getWallets().filter((w): w is LightningArkWallet => w instanceof LightningArkWallet); + for (const wallet of wallets) { + const namespace = wallet.getNamespace(); + const perWallet = swapStatusCache.get(namespace); + if (perWallet && perWallet.size > 0) { + triggerRefreshForWallet(wallet.getID()); + } + } + + state.lastReconciledAt = Date.now(); +} + +// Exported for tests only. +export const __testing__ = { + state, + swapStatusCache, + lastSeenActionMap, + resetConfigured: (): void => { + configured = false; + }, + setMaxRunMs: (ms: number): void => { + maxRunMs = ms; + }, + reset: (): void => { + state.lastRegisteredAt = null; + state.lastUnregisteredAt = null; + state.lastRunStartedAt = null; + state.lastRunFinishedAt = null; + state.walletsScanned = 0; + state.swapsPolled = 0; + state.swapsUpdated = 0; + state.lastError = null; + state.exitedDueToUnavailableStorage = false; + state.availability = 'unknown'; + state.lastSwapUpdateAt = 0; + state.lastReconciledAt = 0; + swapStatusCache.clear(); + lastSeenActionMap.clear(); + configured = false; + running = false; + cancelRequested = false; + runDeadline = null; + maxRunMs = DEFAULT_MAX_RUN_MS; + }, +}; diff --git a/blue_modules/arkade-notifications.ts b/blue_modules/arkade-notifications.ts new file mode 100644 index 00000000000..e17515066db --- /dev/null +++ b/blue_modules/arkade-notifications.ts @@ -0,0 +1,163 @@ +// Local-notification posting for actionable Ark swaps. Imported from headless +// background runtimes (no React dependency). +// +// Design notes: +// - Suppression state lives per-wallet in the Arkade Realm +// (RealmNotificationSuppressionRepository), not in a global AsyncStorage +// key — bucket-scoped and encrypted, so the suppression record never +// leaks a stable handle outside the wallet's encryption boundary. +// - Permission and app-level opt-out are checked read-only before each post +// (no prompting from headless context). Suppression is NOT recorded when +// the post is skipped, so a later state where the user grants permission +// triggers a fresh post on the next wake. +// - Notification payload deliberately does NOT include `namespace`. The OS +// notification database persists payloads and is global across BlueWallet +// encryption buckets; embedding a deterministic per-wallet identifier +// would tie a stable handle to the OS-visible record. + +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AppState, Platform } from 'react-native'; +import { Notification, Notifications } from 'react-native-notifications'; +import { checkNotifications, RESULTS } from 'react-native-permissions'; + +import { isChainSwapClaimable, isChainSwapRefundable, isReverseSwapClaimable, isSubmarineSwapRefundable } from '@arkade-os/boltz-swap'; +import type { BoltzSwap } from '@arkade-os/boltz-swap'; + +import loc from '../loc'; +import { NOTIFICATIONS_NO_AND_DONT_ASK_FLAG } from './notifications'; +import type { + RealmNotificationSuppressionRepository, + ArkSwapNotificationAction, +} from './arkade-adapters/realm/notificationSuppressionRepository'; + +export const ARK_SWAP_NOTIFICATION_TYPE = 100; + +const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01'; +let channelEnsured = false; + +export function ensureArkNotificationChannel(): void { + if (Platform.OS !== 'android') return; + if (channelEnsured) return; + channelEnsured = true; + // Reuses the BlueWallet channel from blue_modules/notifications.ts:80-91 so + // headless runs do not register a second channel under a different name. + Notifications.setNotificationChannel({ + channelId: ANDROID_NOTIFICATION_CHANNEL_ID, + name: 'BlueWallet notifications', + description: 'Notifications about incoming payments', + importance: 4, + enableVibration: true, + showBadge: true, + }); +} + +// Channel registration runs lazily on the first post (see notifyArkSwapActionable). +// Calling it at module-top would invoke the native bridge during JS bundle +// evaluation, which racy-blocks RN bootstrap on some devices and breaks +// Detox's RN-context wait. The existing blue_modules/notifications.ts pattern +// also defers channel setup to lazy invocation. + +export function resolveActionableAction(swap: BoltzSwap): ArkSwapNotificationAction | null { + if (isReverseSwapClaimable(swap) || isChainSwapClaimable(swap)) return 'claim'; + if (isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap)) return 'refund'; + return null; +} + +const interpolate = (template: string, walletLabel: string): string => template.replace('{walletLabel}', walletLabel); + +// Static references so scripts/find-unused-loc.js can detect these keys. +const titleFor = (): string => loc.lndViewInvoice.notification_action_title; +const bodyFor = (action: ArkSwapNotificationAction): string => + action === 'claim' ? loc.lndViewInvoice.notification_claim_body : loc.lndViewInvoice.notification_refund_body; + +let appStateOverrideForTest: string | null = null; +let permissionResultOverrideForTest: string | null = null; +let optOutFlagOverrideForTest: string | null | undefined; + +function currentAppState(): string { + return appStateOverrideForTest ?? AppState.currentState; +} + +async function isOsNotificationPermissionGranted(): Promise { + if (permissionResultOverrideForTest !== null) { + return permissionResultOverrideForTest === RESULTS.GRANTED; + } + try { + const { status } = await checkNotifications(); + return status === RESULTS.GRANTED; + } catch { + return false; + } +} + +async function isAppLevelOptedOut(): Promise { + if (optOutFlagOverrideForTest !== undefined) { + return optOutFlagOverrideForTest === 'true'; + } + try { + const flag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + return flag === 'true'; + } catch { + return false; + } +} + +export async function notifyArkSwapActionable( + swap: BoltzSwap, + suppression: RealmNotificationSuppressionRepository, + walletID: string, + walletLabel: string, +): Promise { + const action = resolveActionableAction(swap); + if (!action) return; + + if (currentAppState() === 'active') return; + + if (suppression.has(swap.id, action)) return; + + if (!(await isOsNotificationPermissionGranted())) return; + if (await isAppLevelOptedOut()) return; + + ensureArkNotificationChannel(); + + const title = titleFor(); + const body = interpolate(bodyFor(action), walletLabel); + + try { + Notifications.postLocalNotification( + // namespace is intentionally omitted; tap routing re-derives it from the loaded wallet. + new Notification({ + title, + body, + type: ARK_SWAP_NOTIFICATION_TYPE, + walletID, + swapId: swap.id, + action, + }), + ); + } catch (e: any) { + console.warn('[ArkNotifications] postLocalNotification failed:', e?.message ?? e); + return; + } + + try { + suppression.record(swap.id, action); + } catch (e: any) { + console.warn('[ArkNotifications] suppression.record failed:', e?.message ?? e); + } +} + +export const __testing__ = { + resetChannel: (): void => { + channelEnsured = false; + }, + setAppStateForTest: (state: string | null): void => { + appStateOverrideForTest = state; + }, + setPermissionResultForTest: (result: string | null): void => { + permissionResultOverrideForTest = result; + }, + setOptOutFlagForTest: (value: string | null | undefined): void => { + optOutFlagOverrideForTest = value; + }, +}; diff --git a/blue_modules/base43.js b/blue_modules/base43.js deleted file mode 100644 index 7bbaa8dd7f9..00000000000 --- a/blue_modules/base43.js +++ /dev/null @@ -1,14 +0,0 @@ -const base = require('base-x'); - -const Base43 = { - encode: function () { - throw new Error('not implemented'); - }, - - decode: function (input) { - const x = base('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'); - return x.decode(input).toString('hex'); - }, -}; - -module.exports = Base43; diff --git a/blue_modules/base43.ts b/blue_modules/base43.ts new file mode 100644 index 00000000000..b450cb52ccc --- /dev/null +++ b/blue_modules/base43.ts @@ -0,0 +1,16 @@ +import base from 'base-x'; +import { uint8ArrayToHex } from './uint8array-extras/index'; + +const Base43 = { + encode: function () { + throw new Error('not implemented'); + }, + + decode: function (input: string): string { + const x = base('0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ$*+-./:'); + const uint8 = x.decode(input); + return uint8ArrayToHex(uint8); + }, +}; + +export default Base43; diff --git a/blue_modules/bbqr/consts.ts b/blue_modules/bbqr/consts.ts new file mode 100644 index 00000000000..0c8f292158f --- /dev/null +++ b/blue_modules/bbqr/consts.ts @@ -0,0 +1,327 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * Constants and fixed values. + */ + +import { Version } from './types'; + +// Fixed-length header +export const HEADER_LEN = 8; + +export const FILETYPE_NAMES = { + P: 'PSBT', + T: 'Transaction', + J: 'JSON', + U: 'Unicode Text', + X: 'Executable', + B: 'Binary', + R: 'KT Rx', + S: 'KT Tx', + E: 'KT PSBT', +} as const; + +export const ENCODING_NAMES = { + H: 'HEX', + Z: 'Zlib compressed', + '2': 'Base32', +} as const; + +export const ENCODINGS = new Set(Object.keys(ENCODING_NAMES)); + +export const ENCODING_SPLIT_MOD = { + H: 2, + Z: 8, + '2': 8, +} as const; + +// taken from: https://github.com/mnooner256/pyqrcode/blob/674a77b5eaf850d063f518bd90c243ee34ad6b5d/pyqrcode/tables.py#L84 +export const QR_DATA_CAPACITY = { + 1: { + L: { 0: 152, 1: 41, 2: 25, 4: 17, 8: 10 }, + M: { 0: 128, 1: 34, 2: 20, 4: 14, 8: 8 }, + Q: { 0: 104, 1: 27, 2: 16, 4: 11, 8: 7 }, + H: { 0: 72, 1: 17, 2: 10, 4: 7, 8: 4 }, + }, + 2: { + L: { 0: 272, 1: 77, 2: 47, 4: 32, 8: 20 }, + M: { 0: 224, 1: 63, 2: 38, 4: 26, 8: 16 }, + Q: { 0: 176, 1: 48, 2: 29, 4: 20, 8: 12 }, + H: { 0: 128, 1: 34, 2: 20, 4: 14, 8: 8 }, + }, + 3: { + L: { 0: 440, 1: 127, 2: 77, 4: 53, 8: 32 }, + M: { 0: 352, 1: 101, 2: 61, 4: 42, 8: 26 }, + Q: { 0: 272, 1: 77, 2: 47, 4: 32, 8: 20 }, + H: { 0: 208, 1: 58, 2: 35, 4: 24, 8: 15 }, + }, + 4: { + L: { 0: 640, 1: 187, 2: 114, 4: 78, 8: 48 }, + M: { 0: 512, 1: 149, 2: 90, 4: 62, 8: 38 }, + Q: { 0: 384, 1: 111, 2: 67, 4: 46, 8: 28 }, + H: { 0: 288, 1: 82, 2: 50, 4: 34, 8: 21 }, + }, + 5: { + L: { 0: 864, 1: 255, 2: 154, 4: 106, 8: 65 }, + M: { 0: 688, 1: 202, 2: 122, 4: 84, 8: 52 }, + Q: { 0: 496, 1: 144, 2: 87, 4: 60, 8: 37 }, + H: { 0: 368, 1: 106, 2: 64, 4: 44, 8: 27 }, + }, + 6: { + L: { 0: 1088, 1: 322, 2: 195, 4: 134, 8: 82 }, + M: { 0: 864, 1: 255, 2: 154, 4: 106, 8: 65 }, + Q: { 0: 608, 1: 178, 2: 108, 4: 74, 8: 45 }, + H: { 0: 480, 1: 139, 2: 84, 4: 58, 8: 36 }, + }, + 7: { + L: { 0: 1248, 1: 370, 2: 224, 4: 154, 8: 95 }, + M: { 0: 992, 1: 293, 2: 178, 4: 122, 8: 75 }, + Q: { 0: 704, 1: 207, 2: 125, 4: 86, 8: 53 }, + H: { 0: 528, 1: 154, 2: 93, 4: 64, 8: 39 }, + }, + 8: { + L: { 0: 1552, 1: 461, 2: 279, 4: 192, 8: 118 }, + M: { 0: 1232, 1: 365, 2: 221, 4: 152, 8: 93 }, + Q: { 0: 880, 1: 259, 2: 157, 4: 108, 8: 66 }, + H: { 0: 688, 1: 202, 2: 122, 4: 84, 8: 52 }, + }, + 9: { + L: { 0: 1856, 1: 552, 2: 335, 4: 230, 8: 141 }, + M: { 0: 1456, 1: 432, 2: 262, 4: 180, 8: 111 }, + Q: { 0: 1056, 1: 312, 2: 189, 4: 130, 8: 80 }, + H: { 0: 800, 1: 235, 2: 143, 4: 98, 8: 60 }, + }, + 10: { + L: { 0: 2192, 1: 652, 2: 395, 4: 271, 8: 167 }, + M: { 0: 1728, 1: 513, 2: 311, 4: 213, 8: 131 }, + Q: { 0: 1232, 1: 364, 2: 221, 4: 151, 8: 93 }, + H: { 0: 976, 1: 288, 2: 174, 4: 119, 8: 74 }, + }, + 11: { + L: { 0: 2592, 1: 772, 2: 468, 4: 321, 8: 198 }, + M: { 0: 2032, 1: 604, 2: 366, 4: 251, 8: 155 }, + Q: { 0: 1440, 1: 427, 2: 259, 4: 177, 8: 109 }, + H: { 0: 1120, 1: 331, 2: 200, 4: 137, 8: 85 }, + }, + 12: { + L: { 0: 2960, 1: 883, 2: 535, 4: 367, 8: 226 }, + M: { 0: 2320, 1: 691, 2: 419, 4: 287, 8: 177 }, + Q: { 0: 1648, 1: 489, 2: 296, 4: 203, 8: 125 }, + H: { 0: 1264, 1: 374, 2: 227, 4: 155, 8: 96 }, + }, + 13: { + L: { 0: 3424, 1: 1022, 2: 619, 4: 425, 8: 262 }, + M: { 0: 2672, 1: 796, 2: 483, 4: 331, 8: 204 }, + Q: { 0: 1952, 1: 580, 2: 352, 4: 241, 8: 149 }, + H: { 0: 1440, 1: 427, 2: 259, 4: 177, 8: 109 }, + }, + 14: { + L: { 0: 3688, 1: 1101, 2: 667, 4: 458, 8: 282 }, + M: { 0: 2920, 1: 871, 2: 528, 4: 362, 8: 223 }, + Q: { 0: 2088, 1: 621, 2: 376, 4: 258, 8: 159 }, + H: { 0: 1576, 1: 468, 2: 283, 4: 194, 8: 120 }, + }, + 15: { + L: { 0: 4184, 1: 1250, 2: 758, 4: 520, 8: 320 }, + M: { 0: 3320, 1: 991, 2: 600, 4: 412, 8: 254 }, + Q: { 0: 2360, 1: 703, 2: 426, 4: 292, 8: 180 }, + H: { 0: 1784, 1: 530, 2: 321, 4: 220, 8: 136 }, + }, + 16: { + L: { 0: 4712, 1: 1408, 2: 854, 4: 586, 8: 361 }, + M: { 0: 3624, 1: 1082, 2: 656, 4: 450, 8: 277 }, + Q: { 0: 2600, 1: 775, 2: 470, 4: 322, 8: 198 }, + H: { 0: 2024, 1: 602, 2: 365, 4: 250, 8: 154 }, + }, + 17: { + L: { 0: 5176, 1: 1548, 2: 938, 4: 644, 8: 397 }, + M: { 0: 4056, 1: 1212, 2: 734, 4: 504, 8: 310 }, + Q: { 0: 2936, 1: 876, 2: 531, 4: 364, 8: 224 }, + H: { 0: 2264, 1: 674, 2: 408, 4: 280, 8: 173 }, + }, + 18: { + L: { 0: 5768, 1: 1725, 2: 1046, 4: 718, 8: 442 }, + M: { 0: 4504, 1: 1346, 2: 816, 4: 560, 8: 345 }, + Q: { 0: 3176, 1: 948, 2: 574, 4: 394, 8: 243 }, + H: { 0: 2504, 1: 746, 2: 452, 4: 310, 8: 191 }, + }, + 19: { + L: { 0: 6360, 1: 1903, 2: 1153, 4: 792, 8: 488 }, + M: { 0: 5016, 1: 1500, 2: 909, 4: 624, 8: 384 }, + Q: { 0: 3560, 1: 1063, 2: 644, 4: 442, 8: 272 }, + H: { 0: 2728, 1: 813, 2: 493, 4: 338, 8: 208 }, + }, + 20: { + L: { 0: 6888, 1: 2061, 2: 1249, 4: 858, 8: 528 }, + M: { 0: 5352, 1: 1600, 2: 970, 4: 666, 8: 410 }, + Q: { 0: 3880, 1: 1159, 2: 702, 4: 482, 8: 297 }, + H: { 0: 3080, 1: 919, 2: 557, 4: 382, 8: 235 }, + }, + 21: { + L: { 0: 7456, 1: 2232, 2: 1352, 4: 929, 8: 572 }, + M: { 0: 5712, 1: 1708, 2: 1035, 4: 711, 8: 438 }, + Q: { 0: 4096, 1: 1224, 2: 742, 4: 509, 8: 314 }, + H: { 0: 3248, 1: 969, 2: 587, 4: 403, 8: 248 }, + }, + 22: { + L: { 0: 8048, 1: 2409, 2: 1460, 4: 1003, 8: 618 }, + M: { 0: 6256, 1: 1872, 2: 1134, 4: 779, 8: 480 }, + Q: { 0: 4544, 1: 1358, 2: 823, 4: 565, 8: 348 }, + H: { 0: 3536, 1: 1056, 2: 640, 4: 439, 8: 270 }, + }, + 23: { + L: { 0: 8752, 1: 2620, 2: 1588, 4: 1091, 8: 672 }, + M: { 0: 6880, 1: 2059, 2: 1248, 4: 857, 8: 528 }, + Q: { 0: 4912, 1: 1468, 2: 890, 4: 611, 8: 376 }, + H: { 0: 3712, 1: 1108, 2: 672, 4: 461, 8: 284 }, + }, + 24: { + L: { 0: 9392, 1: 2812, 2: 1704, 4: 1171, 8: 721 }, + M: { 0: 7312, 1: 2188, 2: 1326, 4: 911, 8: 561 }, + Q: { 0: 5312, 1: 1588, 2: 963, 4: 661, 8: 407 }, + H: { 0: 4112, 1: 1228, 2: 744, 4: 511, 8: 315 }, + }, + 25: { + L: { 0: 10208, 1: 3057, 2: 1853, 4: 1273, 8: 784 }, + M: { 0: 8000, 1: 2395, 2: 1451, 4: 997, 8: 614 }, + Q: { 0: 5744, 1: 1718, 2: 1041, 4: 715, 8: 440 }, + H: { 0: 4304, 1: 1286, 2: 779, 4: 535, 8: 330 }, + }, + 26: { + L: { 0: 10960, 1: 3283, 2: 1990, 4: 1367, 8: 842 }, + M: { 0: 8496, 1: 2544, 2: 1542, 4: 1059, 8: 652 }, + Q: { 0: 6032, 1: 1804, 2: 1094, 4: 751, 8: 462 }, + H: { 0: 4768, 1: 1425, 2: 864, 4: 593, 8: 365 }, + }, + 27: { + L: { 0: 11744, 1: 3514, 2: 2132, 4: 1465, 8: 902 }, + M: { 0: 9024, 1: 2701, 2: 1637, 4: 1125, 8: 692 }, + Q: { 0: 6464, 1: 1933, 2: 1172, 4: 805, 8: 496 }, + H: { 0: 5024, 1: 1501, 2: 910, 4: 625, 8: 385 }, + }, + 28: { + L: { 0: 12248, 1: 3669, 2: 2223, 4: 1528, 8: 940 }, + M: { 0: 9544, 1: 2857, 2: 1732, 4: 1190, 8: 732 }, + Q: { 0: 6968, 1: 2085, 2: 1263, 4: 868, 8: 534 }, + H: { 0: 5288, 1: 1581, 2: 958, 4: 658, 8: 405 }, + }, + 29: { + L: { 0: 13048, 1: 3909, 2: 2369, 4: 1628, 8: 1002 }, + M: { 0: 10136, 1: 3035, 2: 1839, 4: 1264, 8: 778 }, + Q: { 0: 7288, 1: 2181, 2: 1322, 4: 908, 8: 559 }, + H: { 0: 5608, 1: 1677, 2: 1016, 4: 698, 8: 430 }, + }, + 30: { + L: { 0: 13880, 1: 4158, 2: 2520, 4: 1732, 8: 1066 }, + M: { 0: 10984, 1: 3289, 2: 1994, 4: 1370, 8: 843 }, + Q: { 0: 7880, 1: 2358, 2: 1429, 4: 982, 8: 604 }, + H: { 0: 5960, 1: 1782, 2: 1080, 4: 742, 8: 457 }, + }, + 31: { + L: { 0: 14744, 1: 4417, 2: 2677, 4: 1840, 8: 1132 }, + M: { 0: 11640, 1: 3486, 2: 2113, 4: 1452, 8: 894 }, + Q: { 0: 8264, 1: 2473, 2: 1499, 4: 1030, 8: 634 }, + H: { 0: 6344, 1: 1897, 2: 1150, 4: 790, 8: 486 }, + }, + 32: { + L: { 0: 15640, 1: 4686, 2: 2840, 4: 1952, 8: 1201 }, + M: { 0: 12328, 1: 3693, 2: 2238, 4: 1538, 8: 947 }, + Q: { 0: 8920, 1: 2670, 2: 1618, 4: 1112, 8: 684 }, + H: { 0: 6760, 1: 2022, 2: 1226, 4: 842, 8: 518 }, + }, + 33: { + L: { 0: 16568, 1: 4965, 2: 3009, 4: 2068, 8: 1273 }, + M: { 0: 13048, 1: 3909, 2: 2369, 4: 1628, 8: 1002 }, + Q: { 0: 9368, 1: 2805, 2: 1700, 4: 1168, 8: 719 }, + H: { 0: 7208, 1: 2157, 2: 1307, 4: 898, 8: 553 }, + }, + 34: { + L: { 0: 17528, 1: 5253, 2: 3183, 4: 2188, 8: 1347 }, + M: { 0: 13800, 1: 4134, 2: 2506, 4: 1722, 8: 1060 }, + Q: { 0: 9848, 1: 2949, 2: 1787, 4: 1228, 8: 756 }, + H: { 0: 7688, 1: 2301, 2: 1394, 4: 958, 8: 590 }, + }, + 35: { + L: { 0: 18448, 1: 5529, 2: 3351, 4: 2303, 8: 1417 }, + M: { 0: 14496, 1: 4343, 2: 2632, 4: 1809, 8: 1113 }, + Q: { 0: 10288, 1: 3081, 2: 1867, 4: 1283, 8: 790 }, + H: { 0: 7888, 1: 2361, 2: 1431, 4: 983, 8: 605 }, + }, + 36: { + L: { 0: 19472, 1: 5836, 2: 3537, 4: 2431, 8: 1496 }, + M: { 0: 15312, 1: 4588, 2: 2780, 4: 1911, 8: 1176 }, + Q: { 0: 10832, 1: 3244, 2: 1966, 4: 1351, 8: 832 }, + H: { 0: 8432, 1: 2524, 2: 1530, 4: 1051, 8: 647 }, + }, + 37: { + L: { 0: 20528, 1: 6153, 2: 3729, 4: 2563, 8: 1577 }, + M: { 0: 15936, 1: 4775, 2: 2894, 4: 1989, 8: 1224 }, + Q: { 0: 11408, 1: 3417, 2: 2071, 4: 1423, 8: 876 }, + H: { 0: 8768, 1: 2625, 2: 1591, 4: 1093, 8: 673 }, + }, + 38: { + L: { 0: 21616, 1: 6479, 2: 3927, 4: 2699, 8: 1661 }, + M: { 0: 16816, 1: 5039, 2: 3054, 4: 2099, 8: 1292 }, + Q: { 0: 12016, 1: 3599, 2: 2181, 4: 1499, 8: 923 }, + H: { 0: 9136, 1: 2735, 2: 1658, 4: 1139, 8: 701 }, + }, + 39: { + L: { 0: 22496, 1: 6743, 2: 4087, 4: 2809, 8: 1729 }, + M: { 0: 17728, 1: 5313, 2: 3220, 4: 2213, 8: 1362 }, + Q: { 0: 12656, 1: 3791, 2: 2298, 4: 1579, 8: 972 }, + H: { 0: 9776, 1: 2927, 2: 1774, 4: 1219, 8: 750 }, + }, + 40: { + L: { 0: 23648, 1: 7089, 2: 4296, 4: 2953, 8: 1817 }, + M: { 0: 18672, 1: 5596, 2: 3391, 4: 2331, 8: 1435 }, + Q: { 0: 13328, 1: 3993, 2: 2420, 4: 1663, 8: 1024 }, + H: { 0: 10208, 1: 3057, 2: 1852, 4: 1273, 8: 784 }, + }, +} as const; + +// map version to size in modules +// https://github.com/mnooner256/pyqrcode/blob/674a77b5eaf850d063f518bd90c243ee34ad6b5d/pyqrcode/tables.py#L71 +export const QR_SIZE: Record = { + 1: 21, + 2: 25, + 3: 29, + 4: 33, + 5: 37, + 6: 41, + 7: 45, + 8: 49, + 9: 53, + 10: 57, + 11: 61, + 12: 65, + 13: 69, + 14: 73, + 15: 77, + 16: 81, + 17: 85, + 18: 89, + 19: 93, + 20: 97, + 21: 101, + 22: 105, + 23: 109, + 24: 113, + 25: 117, + 26: 121, + 27: 125, + 28: 129, + 29: 133, + 30: 137, + 31: 141, + 32: 145, + 33: 149, + 34: 153, + 35: 157, + 36: 161, + 37: 165, + 38: 169, + 39: 173, + 40: 177, +} as const; + +// EOF diff --git a/blue_modules/bbqr/join.ts b/blue_modules/bbqr/join.ts new file mode 100644 index 00000000000..e14396051f3 --- /dev/null +++ b/blue_modules/bbqr/join.ts @@ -0,0 +1,81 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * QR code decoding/joining. + */ + +import { ENCODINGS } from './consts'; +import { Encoding, JoinResult } from './types'; +import { decodeData } from './utils'; + +/** + * Decodes and joins QR code parts back to binary data. + * + * @param parts Array of QR code parts + * @returns Object containing the file type, encoding, and raw binary data. + */ +export function joinQRs(parts: string[]): JoinResult { + const headers = new Set(parts.map(p => p.slice(0, 6))); + + if (headers.size !== 1) { + throw new Error('conflicting/variable filetype/encodings/sizes'); + } + + const header = [...headers][0]; + + if (header.slice(0, 2) !== 'B$') { + throw new Error('fixed header not found, expected B$'); + } + + if (!ENCODINGS.has(header[2])) { + throw new Error(`bad encoding: ${header[2]}`); + } + + const encoding = header[2] as Encoding; + const fileType = header[3]; + + if (!/^[A-Z]$/.test(fileType)) { + throw new Error('fileType must be a single uppercase letter'); + } + + const numParts = parseInt(header.slice(4, 6), 36); + + if (numParts < 1) { + throw new Error('zero parts?'); + } + + const data = new Map(); + + for (const p of parts) { + const idx = parseInt(p.slice(6, 8), 36); + + if (idx >= numParts) { + throw new Error(`got part ${idx} but only expecting ${numParts}`); + } + + if (data.has(idx) && data.get(idx) !== p.slice(8)) { + throw new Error(`Duplicate part 0x${idx.toString(16)} has wrong content`); + } + + data.set(idx, p.slice(8)); + } + + const orderedParts = []; + + for (let i = 0; i < numParts; i++) { + const p = data.get(i); + + if (!p) { + throw new Error(`Part ${i} is missing`); + } + + orderedParts.push(p); + } + + const raw = decodeData(orderedParts, encoding); + + // @ts-ignore + return { fileType, encoding, raw }; +} + +// EOF diff --git a/blue_modules/bbqr/main.ts b/blue_modules/bbqr/main.ts new file mode 100644 index 00000000000..72e24cd541b --- /dev/null +++ b/blue_modules/bbqr/main.ts @@ -0,0 +1,14 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * Main entry point for the library. + */ + +// import { renderQRImage } from './image.ts'; +import { joinQRs } from './join.ts'; +import { detectFileType, splitQRs } from './split.ts'; + +export * from './types'; +export { detectFileType, joinQRs, splitQRs }; + +// EOF diff --git a/blue_modules/bbqr/split.ts b/blue_modules/bbqr/split.ts new file mode 100644 index 00000000000..d58f441bcf5 --- /dev/null +++ b/blue_modules/bbqr/split.ts @@ -0,0 +1,202 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * Splitting of data and encoding as BBQr QR codes. + */ + +import { ENCODING_SPLIT_MOD, HEADER_LEN } from './consts'; +import { Encoding, FileType, SplitOptions, SplitResult, Version } from './types'; +import { + base64ToBytes, + encodeData, + fileToBytes, + hexToBytes, + intToBase36, + looksLikePsbt, + validateSplitOptions, + versionToChars, +} from './utils'; + +function numQRNeeded(version: Version, length: number, encoding: Encoding) { + const splitMod = ENCODING_SPLIT_MOD[encoding]; + + const baseCap = versionToChars(version) - HEADER_LEN; + + // adjust capacity to be a multiple of splitMod + const adjustedCap = baseCap - (baseCap % splitMod); + + const estimatedCount = Math.ceil(length / adjustedCap); + + if (estimatedCount === 1) { + // if it fits in one QR, we're done + return { count: 1, perEach: length }; + } + + // the total capacity of our estimated count + // all but the last QR need to use adjusted capacity to ensure proper split + const estimatedCap = (estimatedCount - 1) * adjustedCap + baseCap; + + return { + count: estimatedCap >= length ? estimatedCount : estimatedCount + 1, + perEach: adjustedCap, + }; +} + +function findBestVersion(length: number, opts: Required) { + const options: { version: Version; count: number; perEach: number }[] = []; + + for (let version = opts.minVersion; version <= opts.maxVersion; version++) { + const { count, perEach } = numQRNeeded(version, length, opts.encoding); + + if (opts.minSplit <= count && count <= opts.maxSplit) { + options.push({ version, count, perEach }); + } + } + + if (!options.length) { + throw new Error('Cannot make it fit'); + } + + // pick smallest number of QR, lowest version + options.sort((a, b) => a.count - b.count || a.version - b.version); + + return options[0]; +} + +/** + * Converts the input bytes into a series of QR codes, ensuring that the most efficient QR code + * version is used. + * + * NOTE: When the default 'Z' (Zlib) encoding is selected, it is possible that the actual used encoding + * will be '2' (Base32) in case Zlib compression does not reduce the size of the output. + * + * @param raw The input bytes to split and encode. + * @param fileType The file type to use. Refer to BBQr spec. + * @param opts An optional SplitOptions object. + * + * @returns An object containing the version of the QR codes, their string parts, and the actual encoding used. + */ +export function splitQRs( + raw: Uint8Array, + fileType: string, + opts: SplitOptions = {} +): SplitResult { + if (!/^[A-Z]$/.test(fileType)) { + throw new Error('fileType must be a single uppercase letter A-Z'); + } + + const validatedOpts = validateSplitOptions(opts); + + const { encoding: actualEncoding, encoded } = encodeData(raw, validatedOpts.encoding); + + const { version, count, perEach } = findBestVersion(encoded.length, validatedOpts); + + const parts: string[] = []; + + for (let n = 0, offset = 0; offset < encoded.length; n++, offset += perEach) { + parts.push( + `B$${actualEncoding}${fileType}` + + intToBase36(count) + + intToBase36(n) + + encoded.slice(offset, offset + perEach) + ); + } + + return { version, parts, encoding: actualEncoding }; +} + +/** + * Takes a given given input (Uint8Array, File, or string) and detects its FileType. + * PSBTs and Bitcoin transactions are supported in raw binary, Base64, or hex format. + * + * @param input - The input to detect the FileType of. + * @returns A Promise that resolves to an object containing the FileType and raw data. + */ +export async function detectFileType( + input: File | Uint8Array | string +): Promise<{ fileType: FileType; raw: Uint8Array }> { + // keep references to both raw and decoded versions of the input to run checks on + let raw: Uint8Array | undefined = undefined; + let decoded: string | undefined = undefined; + + if (input instanceof File) { + // convert a File to Uint8Array so we have access to the raw bytes + input = await fileToBytes(input); + } + + if (input instanceof Uint8Array) { + // we got binary, see if we recognize it + raw = input; + + if (looksLikePsbt(input)) { + console.debug('Detected type "P" from binary input'); + return { fileType: 'P', raw }; + } + + if (raw[0] === 0x01 || raw[0] === 0x02) { + console.debug('Detected type "T" from binary input'); + return { fileType: 'T', raw }; + } + + // otherwise, try to decode as text (could be contents of a file) + try { + decoded = new TextDecoder('utf-8', { fatal: true }).decode(raw); + } catch (err) { + // not text, so fall back to generic binary + + console.debug('Detected type "B" from binary input'); + return { fileType: 'B', raw }; + } + } else if (typeof input === 'string') { + decoded = input; + } else { + throw new Error('Invalid input - must be a File, Uint8Array or string'); + } + + const trimmed = decoded.trim(); + + if (/^70736274ff[0-9A-Fa-f]+$/.test(trimmed)) { + // PSBT in hex format + console.debug('Detected type "P" from hex input'); + + return { fileType: 'P', raw: hexToBytes(trimmed) }; + } + + if (/^0[1,2]000000[0-9A-Fa-f]+$/.test(trimmed)) { + // Transaction in hex format + console.debug('Detected type "T" from hex input'); + + return { fileType: 'T', raw: hexToBytes(trimmed) }; + } + + if (/^[A-Za-z0-9+/=]+$/.test(trimmed)) { + // looks like base64 - could be PSBT or transaction + const bytes = base64ToBytes(decoded); + + if (looksLikePsbt(bytes)) { + console.debug('Detected type "P" from base64 input'); + return { fileType: 'P', raw: bytes }; + } + + if (bytes[0] === 0x01 || bytes[0] === 0x02) { + console.debug('Detected type "T" from base64 input'); + return { fileType: 'T', raw: bytes }; + } + } + + // ensure we have raw bytes for the next step + raw = raw ?? new TextEncoder().encode(decoded); + + try { + JSON.parse(decoded); + console.debug('Detected type "J"'); + return { fileType: 'J', raw }; + } catch (err) { + // not JSON - fall back to generic Unicode + + console.debug('Detected type "U"'); + return { fileType: 'U', raw }; + } +} + +// EOF diff --git a/blue_modules/bbqr/types.ts b/blue_modules/bbqr/types.ts new file mode 100644 index 00000000000..36ebb48418e --- /dev/null +++ b/blue_modules/bbqr/types.ts @@ -0,0 +1,90 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * Types + */ + +import { ENCODING_NAMES, FILETYPE_NAMES, QR_DATA_CAPACITY } from './consts'; + +export type FileType = keyof typeof FILETYPE_NAMES; +export type Encoding = keyof typeof ENCODING_NAMES; +export type Version = keyof typeof QR_DATA_CAPACITY; + +export type SplitOptions = { + /** + * The encoding to use for the split. + * @default 'Z' + */ + encoding?: Encoding; + /** + * The minimum number of QR codes to use. + * @default 1 + */ + minSplit?: number; + /** + * The maximum number of QR codes to use. + * @default 1295 + */ + maxSplit?: number; + /** + * The minimum version of QR code to use. + * @default 5 + */ + minVersion?: Version; + /** + * The maximum version of QR code to use. + * @default 40 + */ + maxVersion?: Version; +}; + +export type SplitResult = { + version: Version; + parts: string[]; + encoding: Encoding; +}; + +export type JoinResult = { + fileType: string; + encoding: Encoding; + raw: Uint8Array; +}; + +export type ImageOptions = { + /** + * The type of PNG image to render: + * + * - `animated`: An animated PNG (APNG) with a delay between frames. + * - `stacked`: A single PNG image with QR codes stacked vertically. + * + * @default `animated` + */ + mode?: 'animated' | 'stacked'; + /** + * The delay between frames in the animated PNG in milliseconds. + * Ignored if `mode` is `stacked`. + * @default 250 + */ + frameDelay?: number; + /** + * Whether to randomize the order of the parts. + * Ignored if `mode` is `stacked`. + * @default false. + */ + randomizeOrder?: boolean; + /** + * The scale factor of of the QR code images. + * A scale of 1 means 1 pixel per QR module (black dot). + * @default 4 + */ + scale?: number; + /** + * The margin or "quiet zone" around the QR code. + * Numeric values are interpreted as number of modules. + * Percentage values like `10%` are interpreted as a percentage of the QR code size. + * @default 4 + */ + margin?: number | `${number}%`; +}; + +// EOF diff --git a/blue_modules/bbqr/utils.ts b/blue_modules/bbqr/utils.ts new file mode 100644 index 00000000000..d442a058889 --- /dev/null +++ b/blue_modules/bbqr/utils.ts @@ -0,0 +1,212 @@ +/** + * (c) Copyright 2024 by Coinkite Inc. This file is in the public domain. + * + * Helper/utility functions. + */ + +import { base32 } from '@scure/base'; +// @ts-ignore not installing types +import pako from 'pako'; +import { QR_DATA_CAPACITY } from './consts'; +import type { Encoding, SplitOptions, Version } from './types'; + +export function hexToBytes(hex: string) { + // convert a hex string to a Uint8Array + + const match = hex.match(/.{1,2}/g) ?? []; + + return Uint8Array.from(match.map(byte => parseInt(byte, 16))); +} + +export function base64ToBytes(base64: string) { + // convert a base64 string to a Uint8Array + + const binaryString = atob(base64); + const len = binaryString.length; + const bytes = new Uint8Array(len); + + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + + return bytes; +} + +export function intToBase36(n: number) { + // convert an integer 0-1295 to two digits of base 36 - 00-ZZ + + if (n < 0 || n > 1295 || !Number.isInteger(n)) { + throw new Error('Out of range'); + } + + return n.toString(36).toUpperCase().padStart(2, '0'); +} + +export async function fileToBytes(file: File) { + // read a File's contents and return as a Uint8Array + + const reader = new FileReader(); + + return new Promise((resolve, reject) => { + reader.onload = e => { + const result = e.target?.result; + + if (result instanceof ArrayBuffer) { + resolve(new Uint8Array(result)); + } else { + reject(new Error('FileReader result is not an ArrayBuffer')); + } + }; + + reader.readAsArrayBuffer(file); + }); +} + +function joinByteParts(parts: Uint8Array[]) { + // perf-optimized way to join Uint8Arrays + + const length = parts.reduce((acc, bytes) => acc + bytes.length, 0); + + const rv = new Uint8Array(length); + + let offset = 0; + for (const bytes of parts) { + rv.set(bytes, offset); + offset += bytes.length; + } + + return rv; +} + +export function isValidVersion(v: number): v is Version { + // act as a TS type guard but also a runtime check + + return v in QR_DATA_CAPACITY; +} + +export function isValidSplit(s: number) { + return s >= 1 && s <= 1295; +} + +export function validateSplitOptions(opts: SplitOptions) { + // ensure all split options are valid, filling in defaults as needed + + const allOpts = { + minVersion: opts.minVersion ?? 5, + maxVersion: opts.maxVersion ?? 40, + minSplit: opts.minSplit ?? 1, + maxSplit: opts.maxSplit ?? 1295, + encoding: opts.encoding ?? 'Z', + } as const; + + if (allOpts.minVersion > allOpts.maxVersion || !isValidVersion(allOpts.minVersion) || !isValidVersion(allOpts.maxVersion)) { + throw new Error('min/max version out of range'); + } + + if (!isValidSplit(allOpts.minSplit) || !isValidSplit(allOpts.maxSplit) || allOpts.minSplit > allOpts.maxSplit) { + throw new Error('min/max split out of range'); + } + + return allOpts; +} + +export function looksLikePsbt(data: Uint8Array) { + try { + // 'psbt' + 0xff + return new Uint8Array([0x70, 0x73, 0x62, 0x74, 0xff]).every((b, i) => b === data[i]); + } catch (err) { + return false; + } +} + +export function shuffled(arr: T[]): T[] { + // modern Fisher-Yates shuffle (https://en.wikipedia.org/wiki/Fisher–Yates_shuffle#The_modern_algorithm) + + // create a copy so we don't mutate the original + arr = [...arr]; + + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const temp = arr[i]; + arr[i] = arr[j]; + arr[j] = temp; + } + + return arr; +} + +export function versionToChars(v: Version) { + // return number of **chars** that fit into indicated version QR + // - assumes L for ECC + // - assumes alnum encoding + + if (!isValidVersion(v)) { + throw new Error('Invalid version'); + } + + const ecc = 'L'; + const encoding = 2; // alnum + + return QR_DATA_CAPACITY[v][ecc][encoding]; +} + +export function encodeData(raw: Uint8Array, encoding?: Encoding) { + // return new encoding (if we upgraded) and the + // characters after encoding (a string) + // - default is Zlib or if compression doesn't help, base32 + // - returned data can be split, but must be done modX where X provided + + encoding = encoding ?? 'Z'; + + if (encoding === 'H') { + return { + encoding, + encoded: raw.reduce((acc, byte) => acc + byte.toString(16).padStart(2, '0'), '').toUpperCase(), + }; + } + + if (encoding === 'Z') { + // trial compression, but skip if it embiggens the data + + const compressed = pako.deflate(raw, { windowBits: -10 }); + + // @ts-ignore wont install types + if (compressed.length >= raw.length) { + encoding = '2'; + } else { + encoding = 'Z'; + // @ts-ignore wont install types + raw = compressed; + } + } + + return { + encoding, + // base32 without padding + encoded: base32.encode(raw).replace(/[=]*$/, ''), + }; +} + +export function decodeData(parts: string[], encoding: Encoding) { + // decode the parts back into a Uint8Array + + if (encoding === 'H') { + return joinByteParts(parts.map(p => hexToBytes(p))); + } + + const bytes = joinByteParts( + parts.map(p => { + const padding = (8 - (p.length % 8)) % 8; + + return base32.decode(p + '='.repeat(padding)); + }), + ); + + if (encoding === 'Z') { + return pako.inflate(bytes, { windowBits: -10 }); + } + + return bytes; +} + +// EOF diff --git a/blue_modules/bc-ur/dist/utils.js b/blue_modules/bc-ur/dist/utils.js index 7bfecf77a15..514588dd898 100644 --- a/blue_modules/bc-ur/dist/utils.js +++ b/blue_modules/bc-ur/dist/utils.js @@ -1,10 +1,18 @@ "use strict"; +import { sha256 as _sha256 } from '@noble/hashes/sha256'; Object.defineProperty(exports, "__esModule", { value: true }); exports.compose3 = exports.sha256Hash = void 0; var bitcoinjs_lib_1 = require("bitcoinjs-lib"); +const {uint8ArrayToHex} = require("../../uint8array-extras"); exports.sha256Hash = function (data) { - return bitcoinjs_lib_1.crypto.sha256(data); + return bitcoinjs_crypto_sha256(data); }; + +function bitcoinjs_crypto_sha256(buffer/*: Buffer*/)/*: Buffer*/ { + return Buffer.from(_sha256(Uint8Array.from(buffer))); +} + + exports.compose3 = function (f, g, h) { return function (x) { return f(g(h(x))); }; }; diff --git a/blue_modules/bip39.js b/blue_modules/bip39.js deleted file mode 100644 index afcffd37872..00000000000 --- a/blue_modules/bip39.js +++ /dev/null @@ -1,22 +0,0 @@ -import * as bip39 from 'bip39'; - -const WORDLISTS = [ - bip39.wordlists.english, - bip39.wordlists.french, - bip39.wordlists.spanish, - bip39.wordlists.italian, - bip39.wordlists.japanese, - bip39.wordlists.korean, - bip39.wordlists.chinese_simplified, - bip39.wordlists.chinese_traditional, - bip39.wordlists.czech, - bip39.wordlists.portuguese, -]; - -export function validateMnemonic(mnemonic) { - for (const wordlist of WORDLISTS) { - const valid = bip39.validateMnemonic(mnemonic, wordlist); - if (valid) return true; - } - return false; -} diff --git a/blue_modules/bip39.ts b/blue_modules/bip39.ts new file mode 100644 index 00000000000..347a0d461c9 --- /dev/null +++ b/blue_modules/bip39.ts @@ -0,0 +1,22 @@ +import * as bip39 from 'bip39'; + +const WORDLISTS: string[][] = [ + bip39.wordlists.english, + bip39.wordlists.french, + bip39.wordlists.spanish, + bip39.wordlists.italian, + bip39.wordlists.japanese, + bip39.wordlists.korean, + bip39.wordlists.chinese_simplified, + bip39.wordlists.chinese_traditional, + bip39.wordlists.czech, + bip39.wordlists.portuguese, +]; + +export function validateMnemonic(mnemonic: string) { + for (const wordlist of WORDLISTS) { + const valid = bip39.validateMnemonic(mnemonic, wordlist); + if (valid) return true; + } + return false; +} diff --git a/blue_modules/checksumWords.ts b/blue_modules/checksumWords.ts new file mode 100644 index 00000000000..494c8cd883f --- /dev/null +++ b/blue_modules/checksumWords.ts @@ -0,0 +1,79 @@ +import * as bip39 from 'bip39'; +import { sha256 } from '@noble/hashes/sha256'; + +// partial (11 or 23 word) seed phrase +export function generateChecksumWords(stringSeedPhrase: string) { + const seedPhrase = stringSeedPhrase.toLowerCase().trim().split(' '); + + if ((seedPhrase.length + 1) % 3 > 0) { + return false; // Partial mnemonic size must be multiple of three words, less one. + } + + const wordList = bip39.wordlists[bip39.getDefaultWordlist()]; + + const concatLenBits = seedPhrase.length * 11; + const concatBits = new Array(concatLenBits); + let wordindex = 0; + for (let i = 0; i < seedPhrase.length; i++) { + const word = seedPhrase[i]; + const ndx = wordList.indexOf(word.toLowerCase()); + if (ndx === -1) return false; + // Set the next 11 bits to the value of the index. + for (let ii = 0; ii < 11; ++ii) { + concatBits[wordindex * 11 + ii] = (ndx & (1 << (10 - ii))) !== 0; // eslint-disable-line no-bitwise + } + ++wordindex; + } + + const checksumLengthBits = (concatLenBits + 11) / 33; + const entropyLengthBits = concatLenBits + 11 - checksumLengthBits; + const varyingLengthBits = entropyLengthBits - concatLenBits; + const numPermutations = 2 ** varyingLengthBits; + + const bitPermutations = new Array(numPermutations); + + for (let i = 0; i < numPermutations; i++) { + if (bitPermutations[i] === undefined || bitPermutations[i] === null) bitPermutations[i] = new Array(varyingLengthBits); + for (let j = 0; j < varyingLengthBits; j++) { + bitPermutations[i][j] = ((i >> j) & 1) === 1; // eslint-disable-line no-bitwise + } + } + + const possibleWords = []; + for (let i = 0; i < bitPermutations.length; i++) { + const bitPermutation = bitPermutations[i]; + const entropyBits = new Array(concatLenBits + varyingLengthBits); + entropyBits.splice(0, 0, ...concatBits); + entropyBits.splice(concatBits.length, 0, ...bitPermutation.slice(0, varyingLengthBits)); + + const entropy = new Array(entropyLengthBits / 8); + for (let ii = 0; ii < entropy.length; ++ii) { + for (let jj = 0; jj < 8; ++jj) { + if (entropyBits[ii * 8 + jj]) { + entropy[ii] |= 1 << (7 - jj); // eslint-disable-line no-bitwise + } + } + } + + const hash = sha256(new Uint8Array(entropy)); + + const hashBits = new Array(hash.length * 8); + for (let iq = 0; iq < hash.length; ++iq) for (let jq = 0; jq < 8; ++jq) hashBits[iq * 8 + jq] = (hash[iq] & (1 << (7 - jq))) !== 0; // eslint-disable-line no-bitwise + + const wordBits = new Array(11); + wordBits.splice(0, 0, ...bitPermutation.slice(0, varyingLengthBits)); + wordBits.splice(varyingLengthBits, 0, ...hashBits.slice(0, checksumLengthBits)); + + let index = 0; + for (let j = 0; j < 11; ++j) { + index <<= 1; // eslint-disable-line no-bitwise + if (wordBits[j]) { + index |= 0x1; // eslint-disable-line no-bitwise + } + } + + possibleWords.push(wordList[index]); + } + + return possibleWords; +} diff --git a/blue_modules/clipboard.ts b/blue_modules/clipboard.ts index 8a8994834fd..e26409d7c8d 100644 --- a/blue_modules/clipboard.ts +++ b/blue_modules/clipboard.ts @@ -1,42 +1,40 @@ -import { useAsyncStorage } from '@react-native-async-storage/async-storage'; +import AsyncStorage from '@react-native-async-storage/async-storage'; import Clipboard from '@react-native-clipboard/clipboard'; -const BlueClipboard = () => { - const STORAGE_KEY = 'ClipboardReadAllowed'; - const { getItem, setItem } = useAsyncStorage(STORAGE_KEY); +const STORAGE_KEY: string = 'ClipboardReadAllowed'; - const isReadClipboardAllowed = async () => { - try { - const clipboardAccessAllowed = await getItem(); - if (clipboardAccessAllowed === null) { - await setItem(JSON.stringify(true)); - return true; - } - return !!JSON.parse(clipboardAccessAllowed); - } catch { - await setItem(JSON.stringify(true)); +export const isReadClipboardAllowed = async (): Promise => { + try { + const clipboardAccessAllowed = await AsyncStorage.getItem(STORAGE_KEY); + if (clipboardAccessAllowed === null) { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(true)); return true; } - }; + return !!JSON.parse(clipboardAccessAllowed); + } catch { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(true)); + return true; + } +}; - const setReadClipboardAllowed = (value: boolean) => { - setItem(JSON.stringify(!!value)); - }; +export const setReadClipboardAllowed = async (value: boolean): Promise => { + try { + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(Boolean(value))); + } catch (error) { + console.error('Failed to set clipboard permission:', error); + throw error; + } +}; - const getClipboardContent = async () => { +export const getClipboardContent = async (): Promise => { + try { const isAllowed = await isReadClipboardAllowed(); - if (isAllowed) { - return Clipboard.getString(); - } else { - return ''; - } - }; + if (!isAllowed) return undefined; - return { - isReadClipboardAllowed, - setReadClipboardAllowed, - getClipboardContent, - }; + const hasString = await Clipboard.hasString(); + return hasString ? await Clipboard.getString() : undefined; + } catch (error) { + console.error('Error accessing clipboard:', error); + return undefined; + } }; - -export default BlueClipboard; diff --git a/blue_modules/constants.js b/blue_modules/constants.js deleted file mode 100644 index 3066fb6b3de..00000000000 --- a/blue_modules/constants.js +++ /dev/null @@ -1,5 +0,0 @@ -/** - * Let's keep config vars, constants and definitions here - */ - -export const groundControlUri = 'https://groundcontrol-bluewallet.herokuapp.com/'; diff --git a/blue_modules/constants.ts b/blue_modules/constants.ts new file mode 100644 index 00000000000..4d74f710a58 --- /dev/null +++ b/blue_modules/constants.ts @@ -0,0 +1,8 @@ +/** + * Let's keep config vars, constants and definitions here + */ + +export const groundControlUri: string = 'https://groundcontrol.bluewallet.io'; + +/** bitcoin-payment-push-service base URL, no trailing slash. Empty = disabled. */ +export const arkadePaymentPushUri: string = 'https://electrum2.bluewallet.io:444'; diff --git a/blue_modules/currency.js b/blue_modules/currency.js deleted file mode 100644 index 7c3424a43ea..00000000000 --- a/blue_modules/currency.js +++ /dev/null @@ -1,260 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -import DefaultPreference from 'react-native-default-preference'; -import * as RNLocalize from 'react-native-localize'; -import BigNumber from 'bignumber.js'; -import { FiatUnit, getFiatRate } from '../models/fiatUnit'; -import WidgetCommunication from './WidgetCommunication'; - -const PREFERRED_CURRENCY_STORAGE_KEY = 'preferredCurrency'; -const EXCHANGE_RATES_STORAGE_KEY = 'currency'; - -let preferredFiatCurrency = FiatUnit.USD; -let exchangeRates = { LAST_UPDATED_ERROR: false }; -let lastTimeUpdateExchangeRateWasCalled = 0; -let skipUpdateExchangeRate = false; - -const LAST_UPDATED = 'LAST_UPDATED'; - -/** - * Saves to storage preferred currency, whole object - * from `./models/fiatUnit` - * - * @param item {Object} one of the values in `./models/fiatUnit` - * @returns {Promise} - */ -async function setPrefferedCurrency(item) { - await AsyncStorage.setItem(PREFERRED_CURRENCY_STORAGE_KEY, JSON.stringify(item)); - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - await DefaultPreference.set('preferredCurrency', item.endPointKey); - await DefaultPreference.set('preferredCurrencyLocale', item.locale.replace('-', '_')); - WidgetCommunication.reloadAllTimelines(); -} - -async function getPreferredCurrency() { - const preferredCurrency = await JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); - await DefaultPreference.setName('group.io.bluewallet.bluewallet'); - await DefaultPreference.set('preferredCurrency', preferredCurrency.endPointKey); - await DefaultPreference.set('preferredCurrencyLocale', preferredCurrency.locale.replace('-', '_')); - return preferredCurrency; -} - -async function _restoreSavedExchangeRatesFromStorage() { - try { - exchangeRates = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - if (!exchangeRates) exchangeRates = { LAST_UPDATED_ERROR: false }; - } catch (_) { - exchangeRates = { LAST_UPDATED_ERROR: false }; - } -} - -async function _restoreSavedPreferredFiatCurrencyFromStorage() { - try { - preferredFiatCurrency = JSON.parse(await AsyncStorage.getItem(PREFERRED_CURRENCY_STORAGE_KEY)); - if (preferredFiatCurrency === null) { - throw Error('No Preferred Fiat selected'); - } - - preferredFiatCurrency = FiatUnit[preferredFiatCurrency.endPointKey] || preferredFiatCurrency; - // ^^^ in case configuration in json file changed (and is different from what we stored) we reload it - } catch (_) { - const deviceCurrencies = RNLocalize.getCurrencies(); - if (Object.keys(FiatUnit).some(unit => unit === deviceCurrencies[0])) { - preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; - } else { - preferredFiatCurrency = FiatUnit.USD; - } - } -} - -/** - * actual function to reach api and get fresh currency exchange rate. checks LAST_UPDATED time and skips entirely - * if called too soon (30min); saves exchange rate (with LAST_UPDATED info) to storage. - * should be called when app thinks its a good time to refresh exchange rate - * - * @return {Promise} - */ -async function updateExchangeRate() { - if (skipUpdateExchangeRate) return; - if (+new Date() - lastTimeUpdateExchangeRateWasCalled <= 10 * 1000) { - // simple debounce so theres no race conditions - return; - } - lastTimeUpdateExchangeRateWasCalled = +new Date(); - - if (+new Date() - exchangeRates[LAST_UPDATED] <= 30 * 60 * 1000) { - // not updating too often - return; - } - console.log('updating exchange rate...'); - - let rate; - try { - rate = await getFiatRate(preferredFiatCurrency.endPointKey); - exchangeRates[LAST_UPDATED] = +new Date(); - exchangeRates['BTC_' + preferredFiatCurrency.endPointKey] = rate; - exchangeRates.LAST_UPDATED_ERROR = false; - await AsyncStorage.setItem(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(exchangeRates)); - } catch (Err) { - console.log('Error encountered when attempting to update exchange rate...'); - console.warn(Err.message); - rate = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - rate.LAST_UPDATED_ERROR = true; - exchangeRates.LAST_UPDATED_ERROR = true; - await AsyncStorage.setItem(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(rate)); - throw Err; - } -} - -async function isRateOutdated() { - try { - const rate = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - return rate.LAST_UPDATED_ERROR || +new Date() - rate.LAST_UPDATED >= 31 * 60 * 1000; - } catch { - return true; - } -} - -/** - * this function reads storage and restores current preferred fiat currency & last saved exchange rate, then calls - * updateExchangeRate() to update rates. - * should be called when the app starts and when user changes preferred fiat (with TRUE argument so underlying - * `updateExchangeRate()` would actually update rates via api). - * - * @param clearLastUpdatedTime {boolean} set to TRUE for the underlying - * - * @return {Promise} - */ -async function init(clearLastUpdatedTime = false) { - await _restoreSavedExchangeRatesFromStorage(); - await _restoreSavedPreferredFiatCurrencyFromStorage(); - - if (clearLastUpdatedTime) { - exchangeRates[LAST_UPDATED] = 0; - lastTimeUpdateExchangeRateWasCalled = 0; - } - - return updateExchangeRate(); -} - -function satoshiToLocalCurrency(satoshi, format = true) { - if (!exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]) { - updateExchangeRate(); - return '...'; - } - - let b = new BigNumber(satoshi).dividedBy(100000000).multipliedBy(exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]); - - if (b.isGreaterThanOrEqualTo(0.005) || b.isLessThanOrEqualTo(-0.005)) { - b = b.toFixed(2); - } else { - b = b.toPrecision(2); - } - - if (format === false) return b; - - let formatter; - try { - formatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - minimumFractionDigits: 2, - maximumFractionDigits: 8, - }); - } catch (error) { - console.warn(error); - console.log(error); - formatter = new Intl.NumberFormat(FiatUnit.USD.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - minimumFractionDigits: 2, - maximumFractionDigits: 8, - }); - } - - return formatter.format(b); -} - -function BTCToLocalCurrency(bitcoin) { - let sat = new BigNumber(bitcoin); - sat = sat.multipliedBy(100000000).toNumber(); - - return satoshiToLocalCurrency(sat); -} - -async function mostRecentFetchedRate() { - const currencyInformation = JSON.parse(await AsyncStorage.getItem(EXCHANGE_RATES_STORAGE_KEY)); - - const formatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { - style: 'currency', - currency: preferredFiatCurrency.endPointKey, - }); - return { - LastUpdated: currencyInformation[LAST_UPDATED], - Rate: formatter.format(currencyInformation[`BTC_${preferredFiatCurrency.endPointKey}`]), - }; -} - -function satoshiToBTC(satoshi) { - let b = new BigNumber(satoshi); - b = b.dividedBy(100000000); - return b.toString(10); -} - -function btcToSatoshi(btc) { - return new BigNumber(btc).multipliedBy(100000000).toNumber(); -} - -function fiatToBTC(fiatFloat) { - let b = new BigNumber(fiatFloat); - b = b.dividedBy(exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]).toFixed(8); - return b; -} - -function getCurrencySymbol() { - return preferredFiatCurrency.symbol; -} - -/** - * Used to mock data in tests - * - * @param {object} currency, one of FiatUnit.* - */ -function _setPreferredFiatCurrency(currency) { - preferredFiatCurrency = currency; -} - -/** - * Used to mock data in tests - * - * @param {string} pair as expected by rest of this module, e.g 'BTC_JPY' or 'BTC_USD' - * @param {number} rate exchange rate - */ -function _setExchangeRate(pair, rate) { - exchangeRates[pair] = rate; -} - -/** - * Used in unit tests, so the `currency` module wont launch actual http request - */ -function _setSkipUpdateExchangeRate() { - skipUpdateExchangeRate = true; -} - -module.exports.updateExchangeRate = updateExchangeRate; -module.exports.init = init; -module.exports.satoshiToLocalCurrency = satoshiToLocalCurrency; -module.exports.fiatToBTC = fiatToBTC; -module.exports.satoshiToBTC = satoshiToBTC; -module.exports.BTCToLocalCurrency = BTCToLocalCurrency; -module.exports.setPrefferedCurrency = setPrefferedCurrency; -module.exports.getPreferredCurrency = getPreferredCurrency; -module.exports.btcToSatoshi = btcToSatoshi; -module.exports.getCurrencySymbol = getCurrencySymbol; -module.exports._setPreferredFiatCurrency = _setPreferredFiatCurrency; // export it to mock data in tests -module.exports._setExchangeRate = _setExchangeRate; // export it to mock data in tests -module.exports._setSkipUpdateExchangeRate = _setSkipUpdateExchangeRate; // export it to mock data in tests -module.exports.PREFERRED_CURRENCY = PREFERRED_CURRENCY_STORAGE_KEY; -module.exports.EXCHANGE_RATES = EXCHANGE_RATES_STORAGE_KEY; -module.exports.LAST_UPDATED = LAST_UPDATED; -module.exports.mostRecentFetchedRate = mostRecentFetchedRate; -module.exports.isRateOutdated = isRateOutdated; diff --git a/blue_modules/currency.ts b/blue_modules/currency.ts new file mode 100644 index 00000000000..acdd3ba158f --- /dev/null +++ b/blue_modules/currency.ts @@ -0,0 +1,402 @@ +import BigNumber from 'bignumber.js'; +import DefaultPreference from 'react-native-default-preference'; +import * as RNLocalize from 'react-native-localize'; + +import { FiatUnit, FiatUnitType, getFiatRate } from '../models/fiatUnit'; + +const PREFERRED_CURRENCY_STORAGE_KEY = 'preferredCurrency'; +const PREFERRED_CURRENCY_LOCALE_STORAGE_KEY = 'preferredCurrencyLocale'; +const EXCHANGE_RATES_STORAGE_KEY = 'exchangeRates'; +const LAST_UPDATED = 'LAST_UPDATED'; +export const GROUP_IO_BLUEWALLET = 'group.io.bluewallet.bluewallet'; +const BTC_PREFIX = 'BTC_'; + +export interface CurrencyRate { + LastUpdated: Date | null; + Rate: number | string | null; +} + +interface ExchangeRates { + [key: string]: number | boolean | undefined; + LAST_UPDATED_ERROR: boolean; +} + +let preferredFiatCurrency: FiatUnitType = FiatUnit.USD; +let exchangeRates: ExchangeRates = { LAST_UPDATED_ERROR: false }; +let lastTimeUpdateExchangeRateWasCalled: number = 0; +let skipUpdateExchangeRate: boolean = false; + +let currencyFormatter: Intl.NumberFormat | null = null; + +function getCurrencyFormatter(): Intl.NumberFormat { + if ( + !currencyFormatter || + currencyFormatter.resolvedOptions().locale !== preferredFiatCurrency.locale || + currencyFormatter.resolvedOptions().currency !== preferredFiatCurrency.endPointKey + ) { + currencyFormatter = new Intl.NumberFormat(preferredFiatCurrency.locale, { + style: 'currency', + currency: preferredFiatCurrency.endPointKey, + minimumFractionDigits: 2, + maximumFractionDigits: 8, + }); + console.debug('Created new currency formatter for: ', preferredFiatCurrency); + } + return currencyFormatter; +} + +async function setPreferredCurrency(item: FiatUnitType): Promise { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + try { + await DefaultPreference.set(PREFERRED_CURRENCY_STORAGE_KEY, item.endPointKey); + await DefaultPreference.set(PREFERRED_CURRENCY_LOCALE_STORAGE_KEY, item.locale.replace('-', '_')); + preferredFiatCurrency = FiatUnit[item.endPointKey]; + currencyFormatter = null; // Remove cached formatter + console.debug('Preferred currency set to:', item); + console.debug('Preferred currency locale set to:', item.locale.replace('-', '_')); + console.debug('Cleared all cached currency formatters'); + } catch (error) { + console.error('Failed to set preferred currency:', error); + throw error; + } + currencyFormatter = null; +} + +async function updateExchangeRate(): Promise { + if (skipUpdateExchangeRate) return; + if (Date.now() - lastTimeUpdateExchangeRateWasCalled <= 10000) { + // simple debounce so there's no race conditions + return; + } + lastTimeUpdateExchangeRateWasCalled = Date.now(); + + const lastUpdated = exchangeRates[LAST_UPDATED] as number | undefined; + if (lastUpdated && Date.now() - lastUpdated <= 30 * 60 * 1000) { + // not updating too often + return; + } + console.log('updating exchange rate...'); + + try { + const rate = await getFiatRate(preferredFiatCurrency.endPointKey); + exchangeRates[LAST_UPDATED] = Date.now(); + exchangeRates[BTC_PREFIX + preferredFiatCurrency.endPointKey] = rate; + exchangeRates.LAST_UPDATED_ERROR = false; + + try { + const exchangeRatesString = JSON.stringify(exchangeRates); + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(EXCHANGE_RATES_STORAGE_KEY, exchangeRatesString); + } catch (error) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + exchangeRates = { LAST_UPDATED_ERROR: false }; + } + } catch (error) { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const ratesValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let ratesString: string | null = null; + + if (typeof ratesValue === 'string') { + ratesString = ratesValue; + } + + let rate; + if (ratesString) { + try { + rate = JSON.parse(ratesString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + rate = {}; + } + } else { + rate = {}; + } + rate.LAST_UPDATED_ERROR = true; + exchangeRates.LAST_UPDATED_ERROR = true; + await DefaultPreference.set(EXCHANGE_RATES_STORAGE_KEY, JSON.stringify(rate)); + } catch (storageError) { + exchangeRates = { LAST_UPDATED_ERROR: true }; + throw storageError; + } + } +} + +async function getPreferredCurrency(): Promise { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const preferredCurrencyValue = await DefaultPreference.get(PREFERRED_CURRENCY_STORAGE_KEY); + let preferredCurrency: string | null = null; + + if (typeof preferredCurrencyValue === 'string') { + preferredCurrency = preferredCurrencyValue; + } + + if (preferredCurrency) { + try { + if (!FiatUnit[preferredCurrency]) { + throw new Error('Invalid Fiat Unit'); + } + preferredFiatCurrency = FiatUnit[preferredCurrency]; + } catch (error) { + await DefaultPreference.clear(PREFERRED_CURRENCY_STORAGE_KEY); + } + } + + if (!preferredFiatCurrency) { + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } + + await DefaultPreference.set(PREFERRED_CURRENCY_LOCALE_STORAGE_KEY, preferredFiatCurrency.locale.replace('-', '_')); + return preferredFiatCurrency; +} + +async function _restoreSavedExchangeRatesFromStorage(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const ratesValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let ratesString: string | null = null; + + if (typeof ratesValue === 'string') { + ratesString = ratesValue; + } + + if (ratesString) { + try { + const parsedRates = JSON.parse(ratesString); + // Atomic update to prevent race conditions + exchangeRates = parsedRates; + } catch (error) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + exchangeRates = { LAST_UPDATED_ERROR: false }; + // Add delay before update to prevent rapid consecutive calls + await new Promise(resolve => setTimeout(resolve, 1000)); + await updateExchangeRate(); + } + } else { + exchangeRates = { LAST_UPDATED_ERROR: false }; + } + } catch (error) { + exchangeRates = { LAST_UPDATED_ERROR: false }; + await updateExchangeRate(); + } +} + +async function _restoreSavedPreferredFiatCurrencyFromStorage(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const storedCurrencyValue = await DefaultPreference.get(PREFERRED_CURRENCY_STORAGE_KEY); + let storedCurrency: string | null = null; + + if (typeof storedCurrencyValue === 'string') { + storedCurrency = storedCurrencyValue; + } + + if (!storedCurrency) throw new Error('No Preferred Fiat selected'); + + try { + if (!FiatUnit[storedCurrency]) { + throw new Error('Invalid Fiat Unit'); + } + preferredFiatCurrency = FiatUnit[storedCurrency]; + } catch (error) { + await DefaultPreference.clear(PREFERRED_CURRENCY_STORAGE_KEY); + + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } + } catch (error) { + const deviceCurrencies = RNLocalize.getCurrencies(); + if (deviceCurrencies[0] && FiatUnit[deviceCurrencies[0]]) { + preferredFiatCurrency = FiatUnit[deviceCurrencies[0]]; + } else { + preferredFiatCurrency = FiatUnit.USD; + } + } +} + +async function isRateOutdated(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const rateValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let rateString: string | null = null; + + if (typeof rateValue === 'string') { + rateString = rateValue; + } + + let rate; + if (rateString) { + try { + rate = JSON.parse(rateString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + rate = {}; + await updateExchangeRate(); + } + } else { + rate = {}; + } + return rate.LAST_UPDATED_ERROR || Date.now() - (rate[LAST_UPDATED] || 0) >= 31 * 60 * 1000; + } catch { + return true; + } +} + +async function restoreSavedPreferredFiatCurrencyAndExchangeFromStorage(): Promise { + await _restoreSavedExchangeRatesFromStorage(); + await _restoreSavedPreferredFiatCurrencyFromStorage(); +} + +async function initCurrencyDaemon(clearLastUpdatedTime: boolean = false): Promise { + await _restoreSavedExchangeRatesFromStorage(); + await _restoreSavedPreferredFiatCurrencyFromStorage(); + + if (clearLastUpdatedTime) { + exchangeRates[LAST_UPDATED] = 0; + lastTimeUpdateExchangeRateWasCalled = 0; + } + + await updateExchangeRate(); +} + +function satoshiToLocalCurrency(satoshi: number, format: boolean = true): string { + const exchangeRateKey = BTC_PREFIX + preferredFiatCurrency.endPointKey; + const exchangeRate = exchangeRates[exchangeRateKey]; + + if (typeof exchangeRate !== 'number') { + updateExchangeRate(); + return '...'; + } + + const btcAmount = new BigNumber(satoshi).dividedBy(100000000); + const convertedAmount = btcAmount.multipliedBy(exchangeRate); + let formattedAmount: string; + + if (convertedAmount.isGreaterThanOrEqualTo(0.005) || convertedAmount.isLessThanOrEqualTo(-0.005)) { + formattedAmount = convertedAmount.toFixed(2); + } else { + formattedAmount = convertedAmount.toPrecision(2); + } + + if (format === false) return formattedAmount; + + try { + return getCurrencyFormatter().format(Number(formattedAmount)); + } catch (error) { + console.error(error); + return formattedAmount; + } +} + +function BTCToLocalCurrency(bitcoin: BigNumber.Value): string { + const sat = new BigNumber(bitcoin).multipliedBy(100000000).toNumber(); + return satoshiToLocalCurrency(sat); +} + +async function mostRecentFetchedRate(): Promise { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const currencyInfoValue = await DefaultPreference.get(EXCHANGE_RATES_STORAGE_KEY); + let currencyInformationString: string | null = null; + + if (typeof currencyInfoValue === 'string') { + currencyInformationString = currencyInfoValue; + } + + let currencyInformation; + if (currencyInformationString) { + try { + currencyInformation = JSON.parse(currencyInformationString); + } catch (parseError) { + await DefaultPreference.clear(EXCHANGE_RATES_STORAGE_KEY); + currencyInformation = {}; + await updateExchangeRate(); + } + } else { + currencyInformation = {}; + } + + const rate = currencyInformation[BTC_PREFIX + preferredFiatCurrency.endPointKey]; + return { + LastUpdated: currencyInformation[LAST_UPDATED] ? new Date(currencyInformation[LAST_UPDATED]) : null, + Rate: rate ? getCurrencyFormatter().format(rate) : '...', + }; + } catch { + return { + LastUpdated: null, + Rate: null, + }; + } +} + +function satoshiToBTC(satoshi: number): string { + return new BigNumber(satoshi).dividedBy(100000000).toString(10); +} + +function btcToSatoshi(btc: BigNumber.Value): number { + return new BigNumber(btc).multipliedBy(100000000).toNumber(); +} + +function fiatToBTC(fiatFloat: number): string { + const exchangeRateKey = BTC_PREFIX + preferredFiatCurrency.endPointKey; + const exchangeRate = exchangeRates[exchangeRateKey]; + + if (typeof exchangeRate !== 'number') { + throw new Error('Exchange rate not available'); + } + + const btcAmount = new BigNumber(fiatFloat).dividedBy(exchangeRate); + return btcAmount.toFixed(8); +} + +function getCurrencySymbol(): string { + return preferredFiatCurrency.symbol; +} + +function formatBTC(btc: BigNumber.Value): string { + return new BigNumber(btc).toFormat(8); +} + +function _setPreferredFiatCurrency(currency: FiatUnitType): void { + preferredFiatCurrency = currency; +} + +function _setExchangeRate(pair: string, rate: number): void { + exchangeRates[pair] = rate; +} + +function _setSkipUpdateExchangeRate(): void { + skipUpdateExchangeRate = true; +} + +export { + _setExchangeRate, + _setPreferredFiatCurrency, + _setSkipUpdateExchangeRate, + BTCToLocalCurrency, + btcToSatoshi, + EXCHANGE_RATES_STORAGE_KEY, + fiatToBTC, + getCurrencySymbol, + getPreferredCurrency, + initCurrencyDaemon, + isRateOutdated, + LAST_UPDATED, + mostRecentFetchedRate, + PREFERRED_CURRENCY_STORAGE_KEY, + restoreSavedPreferredFiatCurrencyAndExchangeFromStorage, + satoshiToBTC, + satoshiToLocalCurrency, + setPreferredCurrency, + updateExchangeRate, + formatBTC, +}; diff --git a/blue_modules/debounce.js b/blue_modules/debounce.js deleted file mode 100644 index dacfdfefd35..00000000000 --- a/blue_modules/debounce.js +++ /dev/null @@ -1,14 +0,0 @@ -// https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 -const debounce = (func, wait) => { - let timeout; - return function executedFunction(...args) { - const later = () => { - timeout = null; - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -}; - -export default debounce; diff --git a/blue_modules/debounce.ts b/blue_modules/debounce.ts new file mode 100644 index 00000000000..9e04dbe7b01 --- /dev/null +++ b/blue_modules/debounce.ts @@ -0,0 +1,31 @@ +// https://levelup.gitconnected.com/debounce-in-javascript-improve-your-applications-performance-5b01855e086 +// blue_modules/debounce.ts +type DebouncedFunction void> = { + (this: ThisParameterType, ...args: Parameters): void; + cancel(): void; +}; + +const debounce = void>(func: T, wait: number): DebouncedFunction => { + let timeout: NodeJS.Timeout | null; + const debouncedFunction = function (this: ThisParameterType, ...args: Parameters) { + const later = () => { + timeout = null; + func.apply(this, args); + }; + if (timeout) { + clearTimeout(timeout); + } + timeout = setTimeout(later, wait); + }; + + debouncedFunction.cancel = () => { + if (timeout) { + clearTimeout(timeout); + } + timeout = null; + }; + + return debouncedFunction as DebouncedFunction; +}; + +export default debounce; diff --git a/blue_modules/encryption.js b/blue_modules/encryption.js deleted file mode 100644 index 04d8cb60f9b..00000000000 --- a/blue_modules/encryption.js +++ /dev/null @@ -1,22 +0,0 @@ -const CryptoJS = require('crypto-js'); - -module.exports.encrypt = function (data, password) { - if (data.length < 10) throw new Error('data length cant be < 10'); - const ciphertext = CryptoJS.AES.encrypt(data, password); - return ciphertext.toString(); -}; - -module.exports.decrypt = function (data, password) { - const bytes = CryptoJS.AES.decrypt(data, password); - let str = false; - try { - str = bytes.toString(CryptoJS.enc.Utf8); - } catch (e) {} - - // for some reason, sometimes decrypt would succeed with incorrect password and return random couple of characters. - // at least in nodejs environment. so with this little hack we are not alowing to encrypt data that is shorter than - // 10 characters, and thus if decrypted data is less than 10 characters we assume that decrypt actually failed. - if (str.length < 10) return false; - - return str; -}; diff --git a/blue_modules/encryption.ts b/blue_modules/encryption.ts new file mode 100644 index 00000000000..9575e66a859 --- /dev/null +++ b/blue_modules/encryption.ts @@ -0,0 +1,98 @@ +import { cbc } from '@noble/ciphers/aes'; +import { md5 } from '@noble/hashes/legacy'; +import { randomBytes } from '@noble/hashes/utils'; + +import { areUint8ArraysEqual, base64ToUint8Array, concatUint8Arrays, stringToUint8Array, uint8ArrayToBase64 } from './uint8array-extras'; + +/** + * OpenSSL EVP_BytesToKey using MD5 with 1 iteration. + * + * Reproduces the default key+IV derivation used by CryptoJS@4.x's + * `AES.encrypt(string, password)` so the on-disk wire format stays + * bit-identical after we swap the underlying library. + * + * D1 = MD5( password || salt ) + * Di = MD5( D(i-1) || password || salt ) for i ≥ 2 + * key||iv = D1 || D2 || ... (take first `byteLength` bytes) + * + * MD5 is intentional: it matches the legacy OpenSSL format. The + * cryptographic weakness of MD5 is not relevant here — the function is + * only used as a deterministic byte-stretcher; the password's entropy is + * what protects the wallet, not MD5. + */ +export function evpBytesToKeyMd5(password: Uint8Array, salt: Uint8Array, byteLength: number): Uint8Array { + if (!Number.isInteger(byteLength) || byteLength < 0) { + throw new Error('evpBytesToKeyMd5: byteLength must be a non-negative integer'); + } + const out = new Uint8Array(byteLength); + let written = 0; + let prev: Uint8Array = new Uint8Array(0); + while (written < byteLength) { + prev = md5(concatUint8Arrays([prev, password, salt])); + const take = Math.min(prev.length, byteLength - written); + out.set(prev.subarray(0, take), written); + written += take; + } + return out; +} + +// "Salted__" — OpenSSL envelope magic. Hardcoded as bytes so the wire +// format cannot drift through any encoder. +const SALT_MAGIC = new Uint8Array([0x53, 0x61, 0x6c, 0x74, 0x65, 0x64, 0x5f, 0x5f]); +const SALT_LEN = 8; +const KEY_LEN = 32; +const IV_LEN = 16; +const BLOCK_LEN = 16; + +/** + * AES-256-CBC encrypt with the OpenSSL "Salted__" envelope, EVP_BytesToKey-MD5 + * key derivation and PKCS7 padding. Output is base64-encoded. + * + * Wire format is bit-identical to CryptoJS@4.x's default + * `AES.encrypt(data, password).toString()` — we kept the swap-the-library + * change a drop-in replacement so existing encrypted wallets on user + * devices remain readable, with no migration step. + */ +export function encrypt(data: string, password: string): string { + if (data.length < 10) throw new Error('data length cant be < 10'); + const salt = randomBytes(SALT_LEN); + const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN); + const key = kdf.subarray(0, KEY_LEN); + const iv = kdf.subarray(KEY_LEN); + const ciphertext = cbc(key, iv).encrypt(stringToUint8Array(data)); + return uint8ArrayToBase64(concatUint8Arrays([SALT_MAGIC, salt, ciphertext])); +} + +/** + * Inverse of `encrypt`. Accepts the legacy CryptoJS wire format and returns + * the original UTF-8 plaintext. Any error (bad base64, missing magic, wrong + * password, bad padding) collapses to `false`. + */ +export function decrypt(data: string, password: string): string | false { + try { + // crypto-js's base64 decoder ignored whitespace. Some old encrypted-backup + // export/import flows (manual file paste, clipboard transit, email-based + // wallet transfer) introduced stray newlines or padding spaces. Strip them + // before strict base64 decode so legacy backups still open. `\s` does not + // include `=`, so base64 padding survives. + const envelope = base64ToUint8Array(data.replace(/\s+/g, '')); + if (envelope.length < SALT_MAGIC.length + SALT_LEN + BLOCK_LEN) return false; + if (!areUint8ArraysEqual(envelope.subarray(0, SALT_MAGIC.length), SALT_MAGIC)) return false; + const salt = envelope.subarray(SALT_MAGIC.length, SALT_MAGIC.length + SALT_LEN); + const ciphertext = envelope.subarray(SALT_MAGIC.length + SALT_LEN); + const kdf = evpBytesToKeyMd5(stringToUint8Array(password), salt, KEY_LEN + IV_LEN); + const key = kdf.subarray(0, KEY_LEN); + const iv = kdf.subarray(KEY_LEN); + const plain = cbc(key, iv).decrypt(ciphertext); + // Strict UTF-8 decode — wrong-password decrypts that happen to survive + // PKCS7 unpadding overwhelmingly fail here (crypto-js's `enc.Utf8` was + // strict too; we preserve that gate by using `fatal: true`). + const str = new TextDecoder('utf-8', { fatal: true }).decode(plain); + // Belt-and-suspenders: legitimate plaintext is always ≥ 10 chars + // (enforced by encrypt()), so anything shorter is rejected. + if (str.length < 10) return false; + return str; + } catch (e) { + return false; + } +} diff --git a/blue_modules/environment.ts b/blue_modules/environment.ts index f0985a6e24a..e6c285b322f 100644 --- a/blue_modules/environment.ts +++ b/blue_modules/environment.ts @@ -1,41 +1,10 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; -import { isTablet, getDeviceType } from 'react-native-device-info'; +import { getDeviceType, isTablet as checkIsTablet } from 'react-native-device-info'; +const isTablet: boolean = checkIsTablet(); const isDesktop: boolean = getDeviceType() === 'Desktop'; +const isHandset: boolean = getDeviceType() === 'Handset'; -const getIsTorCapable = (): boolean => { - let capable = true; - if (Platform.OS === 'android' && Platform.Version < 26) { - capable = false; - } else if (isDesktop) { - capable = false; - } - return capable; -}; +const isIOS26OrHigher: boolean = Platform.OS === 'ios' && parseInt(String(Platform.Version), 10) >= 26; -const IS_TOR_DAEMON_DISABLED: string = 'is_tor_daemon_disabled'; - -export async function setIsTorDaemonDisabled(disabled: boolean = true): Promise { - return AsyncStorage.setItem(IS_TOR_DAEMON_DISABLED, disabled ? '1' : ''); -} - -export async function isTorDaemonDisabled(): Promise { - let result: boolean; - try { - const savedValue = await AsyncStorage.getItem(IS_TOR_DAEMON_DISABLED); - if (savedValue === null) { - result = false; - } else { - result = savedValue === '1'; - } - } catch { - result = true; - } - - return result; -} - -export const isHandset: boolean = getDeviceType() === 'Handset'; -export const isTorCapable: boolean = getIsTorCapable(); -export { isDesktop, isTablet }; +export { isDesktop, isHandset, isIOS26OrHigher, isTablet }; diff --git a/blue_modules/fs.js b/blue_modules/fs.js deleted file mode 100644 index fd9a8d8e2f2..00000000000 --- a/blue_modules/fs.js +++ /dev/null @@ -1,210 +0,0 @@ -import { Alert, Linking, PermissionsAndroid, Platform } from 'react-native'; -import RNFS from 'react-native-fs'; -import Share from 'react-native-share'; -import loc from '../loc'; -import DocumentPicker from 'react-native-document-picker'; -import { launchCamera, launchImageLibrary } from 'react-native-image-picker'; -import { presentCameraNotAuthorizedAlert } from '../class/camera'; -import { isDesktop } from '../blue_modules/environment'; -import alert from '../components/Alert'; -const LocalQRCode = require('@remobile/react-native-qrcode-local-image'); - -const writeFileAndExportToAndroidDestionation = async ({ filename, contents, destinationLocalizedString, destination }) => { - const granted = await PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, { - title: loc.send.permission_storage_title, - message: loc.send.permission_storage_message, - buttonNeutral: loc.send.permission_storage_later, - buttonNegative: loc._.cancel, - buttonPositive: loc._.ok, - }); - if (granted === PermissionsAndroid.RESULTS.GRANTED || Platform.Version >= 33) { - const filePath = destination + `/${filename}`; - try { - await RNFS.writeFile(filePath, contents); - alert(loc.formatString(loc._.file_saved, { filePath: filename, destination: destinationLocalizedString })); - } catch (e) { - console.log(e); - alert(e.message); - } - } else { - console.log('Storage Permission: Denied'); - Alert.alert(loc.send.permission_storage_title, loc.send.permission_storage_denied_message, [ - { - text: loc.send.open_settings, - onPress: () => { - Linking.openSettings(); - }, - style: 'default', - }, - { text: loc._.cancel, onPress: () => {}, style: 'cancel' }, - ]); - } -}; - -const writeFileAndExport = async function (filename, contents) { - if (Platform.OS === 'ios') { - const filePath = RNFS.TemporaryDirectoryPath + `/${filename}`; - await RNFS.writeFile(filePath, contents); - await Share.open({ - url: 'file://' + filePath, - saveToFiles: isDesktop, - }) - .catch(error => { - console.log(error); - }) - .finally(() => { - RNFS.unlink(filePath); - }); - } else if (Platform.OS === 'android') { - await writeFileAndExportToAndroidDestionation({ - filename, - contents, - destinationLocalizedString: loc._.downloads_folder, - destination: RNFS.DownloadDirectoryPath, - }); - } -}; - -/** - * Opens & reads *.psbt files, and returns base64 psbt. FALSE if something went wrong (wont throw). - * - * @returns {Promise} Base64 PSBT - */ -const openSignedTransaction = async function () { - try { - const res = await DocumentPicker.pickSingle({ - type: Platform.OS === 'ios' ? ['io.bluewallet.psbt', 'io.bluewallet.psbt.txn'] : [DocumentPicker.types.allFiles], - }); - - return await _readPsbtFileIntoBase64(res.uri); - } catch (err) { - if (!DocumentPicker.isCancel(err)) { - alert(loc.send.details_no_signed_tx); - } - } - - return false; -}; - -const _readPsbtFileIntoBase64 = async function (uri) { - const base64 = await RNFS.readFile(uri, 'base64'); - const stringData = Buffer.from(base64, 'base64').toString(); // decode from base64 - if (stringData.startsWith('psbt')) { - // file was binary, but outer code expects base64 psbt, so we return base64 we got from rn-fs; - // most likely produced by Electrum-desktop - return base64; - } else { - // file was a text file, having base64 psbt in there. so we basically have double base64encoded string - // thats why we are returning string that was decoded once; - // most likely produced by Coldcard - return stringData; - } -}; - -const showImagePickerAndReadImage = () => { - return new Promise((resolve, reject) => - launchImageLibrary( - { - title: null, - mediaType: 'photo', - takePhotoButtonTitle: null, - maxHeight: 800, - maxWidth: 600, - selectionLimit: 1, - }, - response => { - if (!response.didCancel) { - const asset = response.assets[0]; - if (asset.uri) { - const uri = asset.uri.toString().replace('file://', ''); - LocalQRCode.decode(uri, (error, result) => { - if (!error) { - resolve(result); - } else { - reject(new Error(loc.send.qr_error_no_qrcode)); - } - }); - } - } - }, - ), - ); -}; - -const takePhotoWithImagePickerAndReadPhoto = () => { - return new Promise((resolve, reject) => - launchCamera( - { - title: null, - mediaType: 'photo', - takePhotoButtonTitle: null, - }, - response => { - if (response.uri) { - const uri = response.uri.toString().replace('file://', ''); - LocalQRCode.decode(uri, (error, result) => { - if (!error) { - resolve(result); - } else { - reject(new Error(loc.send.qr_error_no_qrcode)); - } - }); - } else if (response.error) { - presentCameraNotAuthorizedAlert(response.error); - } - }, - ), - ); -}; - -const showFilePickerAndReadFile = async function () { - try { - const res = await DocumentPicker.pickSingle({ - type: - Platform.OS === 'ios' - ? [ - 'io.bluewallet.psbt', - 'io.bluewallet.psbt.txn', - 'io.bluewallet.backup', - DocumentPicker.types.plainText, - 'public.json', - DocumentPicker.types.images, - ] - : [DocumentPicker.types.allFiles], - }); - - const uri = Platform.OS === 'ios' ? decodeURI(res.uri) : res.uri; - // ^^ some weird difference on how spaces in filenames are treated on ios and android - - let file = false; - if (res.uri.toLowerCase().endsWith('.psbt')) { - // this is either binary file from ElectrumDesktop OR string file with base64 string in there - file = await _readPsbtFileIntoBase64(uri); - return { data: file, uri: decodeURI(res.uri) }; - } - - if (res?.type === DocumentPicker.types.images || res?.type?.startsWith('image/')) { - return new Promise(resolve => { - const uri2 = res.uri.toString().replace('file://', ''); - LocalQRCode.decode(decodeURI(uri2), (error, result) => { - if (!error) { - resolve({ data: result, uri: decodeURI(res.uri) }); - } else { - resolve({ data: false, uri: false }); - } - }); - }); - } - - file = await RNFS.readFile(uri); - return { data: file, uri: decodeURI(res.uri) }; - } catch (err) { - return { data: false, uri: false }; - } -}; - -module.exports.writeFileAndExport = writeFileAndExport; -module.exports.openSignedTransaction = openSignedTransaction; -module.exports.showFilePickerAndReadFile = showFilePickerAndReadFile; -module.exports.showImagePickerAndReadImage = showImagePickerAndReadImage; -module.exports.takePhotoWithImagePickerAndReadPhoto = takePhotoWithImagePickerAndReadPhoto; diff --git a/blue_modules/fs.ts b/blue_modules/fs.ts new file mode 100644 index 00000000000..90d502c74f4 --- /dev/null +++ b/blue_modules/fs.ts @@ -0,0 +1,307 @@ +import { Platform } from 'react-native'; +import { pick, types, keepLocalCopy, errorCodes, saveDocuments } from '@react-native-documents/picker'; +import RNFS from 'react-native-fs'; +import { launchImageLibrary, ImagePickerResponse } from 'react-native-image-picker'; +import { detectQRCodeInImage } from 'react-native-camera-kit-no-google'; +import Share from 'react-native-share'; + +import presentAlert from '../components/Alert'; +import loc from '../loc'; +import { isDesktop } from './environment'; +import { readFile } from './react-native-bw-file-access'; +import { base64ToUint8Array, uint8ArrayToString } from './uint8array-extras/index'; + +const _sanitizeFileName = (fileName: string) => { + // Remove any path delimiters and non-alphanumeric characters except for -, _, and . + return fileName.replace(/[^a-zA-Z0-9\-_.]/g, ''); +}; + +export const isCancel = (err: any): boolean => { + return err.code && err.code === errorCodes.OPERATION_CANCELED; +}; + +const _safeUnlink = async (filePath: string) => { + const normalizedPath = decodeURI(filePath).replace(/^file:\/\//, ''); + const candidates = [normalizedPath, filePath].filter((value, index, all) => all.indexOf(value) === index); + + for (const candidate of candidates) { + try { + if (!(await RNFS.exists(candidate))) { + continue; + } + + await RNFS.unlink(candidate); + return; + } catch (error: any) { + const message = String(error?.message || '').toLowerCase(); + const notFound = message.includes('no such file') || message.includes('does not exist') || message.includes('enoent'); + + if (notFound) { + return; + } + + console.warn('Failed to remove temporary file:', candidate, error); + } + } +}; + +const _toFileUri = (filePath: string) => { + return encodeURI(filePath.startsWith('file://') ? filePath : `file://${filePath}`); +}; + +const _createTempExportPath = (fileName: string) => { + const basePath = RNFS.CachesDirectoryPath || RNFS.TemporaryDirectoryPath; + return `${basePath}/${Date.now()}-${fileName}`; +}; + +const _mimeTypeFromFileName = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + switch (extension) { + case 'txt': + case 'txn': + return 'text/plain'; + case 'json': + return 'application/json'; + case 'csv': + return 'text/csv'; + case 'pdf': + return 'application/pdf'; + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + return 'application/octet-stream'; + } +}; + +const _transactionFilePickerTypes = ['application/octet-stream', 'text/plain']; + +const _pickSingleFileAndKeepLocalCopy = async (type: string[] = [types.allFiles]) => { + const [pickedFile] = await pick({ + type, + }); + + if (!pickedFile.hasRequestedType) { + throw new Error(loc.send.details_unrecognized_file_format); + } + + const [localCopy] = await keepLocalCopy({ + files: [ + { + uri: pickedFile.uri, + fileName: pickedFile.name ?? 'unnamed', + }, + ], + destination: 'cachesDirectory', + }); + + if (localCopy.status !== 'success') { + throw new Error(localCopy.copyError || 'Could not create local file copy'); + } + + return { + localUri: decodeURI(localCopy.localUri), + fileName: pickedFile.name ?? 'unnamed', + }; +}; + +const _shareOpen = async (filePath: string, showShareDialog: boolean = false) => { + try { + await Share.open({ + url: 'file://' + filePath, + saveToFiles: isDesktop || !showShareDialog, + failOnCancel: false, + }); + } catch (error: any) { + console.log(error); + // If user cancels sharing, we dont want to show an error. for some reason we get 'CANCELLED' string as error + const errorMessage = typeof error === 'string' ? error : error?.message; + if (errorMessage !== 'CANCELLED') { + presentAlert({ message: errorMessage }); + } + } finally { + await _safeUnlink(filePath); + } +}; + +/** + * Writes a file to fs, and triggers an OS sharing dialog, so user can decide where to put this file (share to cloud + * or perhaps messaging app). Provided filename should be just a file name, NOT a path + */ + +export const writeFileAndExport = async function (fileName: string, contents: string, showShareDialog: boolean = true) { + const sanitizedFileName = _sanitizeFileName(fileName); + try { + if (!showShareDialog) { + const sourceFilePath = _createTempExportPath(sanitizedFileName); + try { + await RNFS.writeFile(sourceFilePath, contents); + const [savedFile] = await saveDocuments({ + sourceUris: [_toFileUri(sourceFilePath)], + fileName: sanitizedFileName, + mimeType: _mimeTypeFromFileName(sanitizedFileName), + copy: true, + }); + + if (savedFile.error) { + throw new Error(savedFile.error); + } + } finally { + await _safeUnlink(sourceFilePath); + } + return; + } + + const filePath = _createTempExportPath(sanitizedFileName); + await RNFS.writeFile(filePath, contents); + await _shareOpen(filePath, true); + } catch (error: any) { + if (isCancel(error)) { + return; + } + console.error(error); + presentAlert({ message: error.message }); + } +}; + +/** + * Opens & reads *.psbt files, and returns base64 psbt. FALSE if something went wrong (wont throw). + */ +export const openSignedTransaction = async function (): Promise { + try { + const { localUri } = await _pickSingleFileAndKeepLocalCopy(_transactionFilePickerTypes); + return await _readPsbtFileIntoBase64(localUri); + } catch (err) { + if (!isCancel(err)) { + presentAlert({ message: loc.send.details_no_signed_tx }); + } + } + + return false; +}; + +const _readPsbtFileIntoBase64 = async function (uri: string): Promise { + const base64 = await RNFS.readFile(uri, 'base64'); + const stringData = uint8ArrayToString(base64ToUint8Array(base64)); // decode from base64 + if (stringData.startsWith('psbt')) { + // file was binary, but outer code expects base64 psbt, so we return base64 we got from rn-fs; + // most likely produced by Electrum-desktop + return base64; + } else { + // file was a text file, having base64 psbt in there. so we basically have double base64encoded string + // thats why we are returning string that was decoded once; + // most likely produced by ColdCard + return stringData; + } +}; + +export const showImagePickerAndReadImage = async (): Promise => { + try { + const response: ImagePickerResponse = await launchImageLibrary({ + mediaType: 'photo', + maxHeight: 800, + maxWidth: 600, + selectionLimit: 1, + includeBase64: true, + }); + + if (response.didCancel) { + return undefined; + } else if (response.errorCode) { + throw new Error(response.errorMessage); + } else if (response.assets) { + const base64 = response.assets[0].base64; + if (base64) { + const result = await detectQRCodeInImage(base64); + if (result) return result; + } + throw new Error(loc.send.qr_error_no_qrcode); + } + + return undefined; + } catch (error: any) { + console.error(error); + throw error; + } +}; + +export const showFilePickerAndReadFile = async function (): Promise<{ data: string | false; uri: string | false }> { + try { + const { localUri: fileCopyUri } = await _pickSingleFileAndKeepLocalCopy(); + const lowerCasePath = fileCopyUri.toLowerCase(); + + if (lowerCasePath.endsWith('.psbt')) { + // this is either binary file from ElectrumDesktop OR string file with base64 string in there + const file = await _readPsbtFileIntoBase64(fileCopyUri); + return { data: file, uri: fileCopyUri }; + } + + if (lowerCasePath.endsWith('.png') || lowerCasePath.endsWith('.jpg') || lowerCasePath.endsWith('.jpeg')) { + return await handleImageFile(fileCopyUri); + } + + const file = await RNFS.readFile(fileCopyUri); + return { data: file, uri: fileCopyUri }; + } catch (err: any) { + if (!isCancel(err)) { + presentAlert({ message: err.message }); + } + return { data: false, uri: false }; + } +}; + +const readFileAsBase64 = async (uri: string): Promise => { + try { + return await RNFS.readFile(uri, 'base64'); + } catch { + return await RNFS.readFile(uri.replace(/^file:\/\//, ''), 'base64'); + } +}; + +const handleImageFile = async (fileCopyUri: string): Promise<{ data: string | false; uri: string | false }> => { + const base64 = await readFileAsBase64(fileCopyUri); + const result = await detectQRCodeInImage(base64); + if (result) { + return { data: result, uri: fileCopyUri }; + } + throw new Error(loc.send.qr_error_no_qrcode); +}; + +export const readFileOutsideSandbox = (filePath: string) => { + if (Platform.OS === 'ios') { + return readFile(filePath); + } else if (Platform.OS === 'android') { + return RNFS.readFile(filePath); + } else { + presentAlert({ message: 'Not implemented for this platform' }); + throw new Error('Not implemented for this platform'); + } +}; + +export const openSignedTransactionRaw: () => Promise = async () => { + try { + const { localUri } = await _pickSingleFileAndKeepLocalCopy(_transactionFilePickerTypes); + const file = await RNFS.readFile(localUri); + if (file) { + return file; + } else { + throw new Error('Could not read file'); + } + } catch (err) { + if (!isCancel(err)) { + presentAlert({ message: loc.send.details_no_signed_tx }); + } + + return ''; + } +}; + +export const pickTransaction = async () => { + const { localUri, fileName } = await _pickSingleFileAndKeepLocalCopy(_transactionFilePickerTypes); + return { + uri: localUri, + name: fileName, + }; +}; diff --git a/blue_modules/hapticFeedback.ts b/blue_modules/hapticFeedback.ts new file mode 100644 index 00000000000..4d9bfd28076 --- /dev/null +++ b/blue_modules/hapticFeedback.ts @@ -0,0 +1,43 @@ +import DeviceInfo, { PowerState } from 'react-native-device-info'; +import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; +import { isDesktop } from './environment'; + +// Define a const enum for HapticFeedbackTypes +export const enum HapticFeedbackTypes { + ImpactLight = 'impactLight', + ImpactMedium = 'impactMedium', + ImpactHeavy = 'impactHeavy', + Selection = 'selection', + NotificationSuccess = 'notificationSuccess', + NotificationWarning = 'notificationWarning', + NotificationError = 'notificationError', +} + +const triggerHapticFeedback = (type: HapticFeedbackTypes) => { + if (isDesktop) return; + DeviceInfo.getPowerState().then((state: Partial) => { + if (!state.lowPowerMode) { + ReactNativeHapticFeedback.trigger(type, { ignoreAndroidSystemSettings: false, enableVibrateFallback: true }); + } else { + console.log('Haptic feedback not triggered due to low power mode.'); + } + }); +}; + +export const triggerSuccessHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); +}; + +export const triggerWarningHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationWarning); +}; + +export const triggerErrorHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); +}; + +export const triggerSelectionHapticFeedback = () => { + triggerHapticFeedback(HapticFeedbackTypes.Selection); +}; + +export default triggerHapticFeedback; diff --git a/blue_modules/net.js b/blue_modules/net.js deleted file mode 100644 index 44d5c0f3a8c..00000000000 --- a/blue_modules/net.js +++ /dev/null @@ -1,110 +0,0 @@ -/** - * @fileOverview adapter for ReactNative TCP module - * This module mimics the nodejs net api and is intended to work in RN environment. - * @see https://github.com/Rapsssito/react-native-tcp-socket - */ - -import TcpSocket from 'react-native-tcp-socket'; - -/** - * Constructor function. Resulting object has to act as it was a real socket (basically - * conform to nodejs/net api) - * - * @constructor - */ -function Socket() { - this._socket = false; // reference to socket thats gona be created later - // defaults: - this._noDelay = true; - - this._listeners = {}; - - // functions not supported by RN module, yet: - this.setTimeout = () => {}; - this.setEncoding = () => {}; - this.setKeepAlive = () => {}; - - // proxying call to real socket object: - this.setNoDelay = noDelay => { - if (this._socket) this._socket.setNoDelay(noDelay); - this._noDelay = noDelay; - }; - - this.connect = (port, host, callback) => { - this._socket = TcpSocket.createConnection( - { - port, - host, - tls: false, - }, - callback, - ); - - this._socket.on('data', data => { - this._passOnEvent('data', data); - }); - this._socket.on('error', data => { - this._passOnEvent('error', data); - }); - this._socket.on('close', data => { - this._passOnEvent('close', data); - }); - this._socket.on('connect', data => { - this._passOnEvent('connect', data); - this._socket.setNoDelay(this._noDelay); - }); - this._socket.on('connection', data => { - this._passOnEvent('connection', data); - }); - }; - - this._passOnEvent = (event, data) => { - this._listeners[event] = this._listeners[event] || []; - for (const savedListener of this._listeners[event]) { - savedListener(data); - } - }; - - this.on = (event, listener) => { - this._listeners[event] = this._listeners[event] || []; - this._listeners[event].push(listener); - }; - - this.removeListener = (event, listener) => { - this._listeners[event] = this._listeners[event] || []; - const newListeners = []; - - let found = false; - for (const savedListener of this._listeners[event]) { - if (savedListener === listener) { - // found our listener - found = true; - // we just skip it - } else { - // other listeners should go back to original array - newListeners.push(savedListener); - } - } - - if (found) { - this._listeners[event] = newListeners; - } else { - // something went wrong, lets just cleanup all listeners - this._listeners[event] = []; - } - }; - - this.end = () => { - this._socket.end(); - }; - - this.destroy = () => { - this._socket.destroy(); - }; - - this.write = data => { - this._socket.write(data); - }; -} - -module.exports.Socket = Socket; diff --git a/blue_modules/noble_ecc.ts b/blue_modules/noble_ecc.ts index 929fdd8865d..db3208da58e 100644 --- a/blue_modules/noble_ecc.ts +++ b/blue_modules/noble_ecc.ts @@ -5,12 +5,12 @@ * @see https://github.com/bitcoinjs/tiny-secp256k1/issues/84#issuecomment-1185682315 * @see https://github.com/bitcoinjs/bitcoinjs-lib/issues/1781 */ -import createHash from 'create-hash'; -import { createHmac } from 'crypto'; import * as necc from '@noble/secp256k1'; -import { TinySecp256k1Interface } from 'ecpair/src/ecpair'; -import { TinySecp256k1Interface as TinySecp256k1InterfaceBIP32 } from 'bip32/types/bip32'; +import { TinySecp256k1Interface as TinySecp256k1InterfaceBIP32 } from 'bip32'; import { XOnlyPointAddTweakResult } from 'bitcoinjs-lib/src/types'; +import { hmac } from '@noble/hashes/hmac'; +import { sha256 } from '@noble/hashes/sha2'; +import { TinySecp256k1Interface } from 'ecpair'; export interface TinySecp256k1InterfaceExtended { pointMultiply(p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null; @@ -20,38 +20,99 @@ export interface TinySecp256k1InterfaceExtended { isXOnlyPoint(p: Uint8Array): boolean; xOnlyPointAddTweak(p: Uint8Array, tweak: Uint8Array): XOnlyPointAddTweakResult | null; -} -necc.utils.sha256Sync = (...messages: Uint8Array[]): Uint8Array => { - const sha256 = createHash('sha256'); - for (const message of messages) sha256.update(message); - return sha256.digest(); -}; + privateNegate(d: Uint8Array): Uint8Array; -necc.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => { - const hash = createHmac('sha256', Buffer.from(key)); - messages.forEach(m => hash.update(m)); - return Uint8Array.from(hash.digest()); -}; + signDER(h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array; +} -/* const normal = necc.utils._normalizePrivateKey; +// @noble/hashes types differ slightly from @noble/secp256k1 v3 hash slot typings. +necc.hashes.sha256 = sha256 as NonNullable; +necc.hashes.hmacSha256 = ((key: Uint8Array, message: Uint8Array) => hmac(sha256, key, message)) as NonNullable< + typeof necc.hashes.hmacSha256 +>; + +// Removed from @noble/secp256k1 v1.7; vendored from noble test vectors. +// @see https://github.com/paulmillr/noble-secp256k1/blob/1.7.2/test/index.ts type Hex = string | Uint8Array; -type PrivKey = Hex | bigint | number; -necc.utils.privateAdd = (privateKey: PrivKey, tweak: Hex) => { - console.log({ privateKey, tweak }); - const p = normal(privateKey); - const t = normal(tweak); - return necc.utils.privateAdd(necc.utils.mod(p + t, necc.CURVE.n)); -}; */ +const { mod, secretKeyToScalar, numberToBytesBE, bytesToNumberBE, hexToBytes } = necc.etc; +const CURVE_N = necc.Point.CURVE().n; + +function pointFromBytes(p: Uint8Array): necc.Point { + if (p.length === 32) { + const prefixed = new Uint8Array(33); + prefixed[0] = 0x02; + prefixed.set(p, 1); + return necc.Point.fromBytes(prefixed); + } + return necc.Point.fromBytes(p); +} + +const tweakUtils = { + privateAdd: (privateKey: Hex, tweak: Hex): Uint8Array => { + const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey); + const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak); + return numberToBytesBE(mod(p + t, CURVE_N)); + }, + + privateNegate: (privateKey: Hex): Uint8Array => { + const p = secretKeyToScalar(typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey); + return numberToBytesBE(CURVE_N - p); + }, + + pointAddScalar: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => { + const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p); + const t = secretKeyToScalar(typeof tweak === 'string' ? hexToBytes(tweak) : tweak); + const Q = P.add(necc.Point.BASE.multiply(t)); + if (Q.is0()) throw new Error('Tweaked point at infinity'); + return Q.toBytes(isCompressed); + }, + + pointMultiply: (p: Hex, tweak: Hex, isCompressed?: boolean): Uint8Array => { + const P = typeof p === 'string' ? necc.Point.fromHex(p) : pointFromBytes(p); + const tweakBytes = typeof tweak === 'string' ? hexToBytes(tweak) : tweak; + const t = mod(bytesToNumberBE(tweakBytes), CURVE_N); + if (t === 0n) throw new Error('Point at infinity'); + return P.multiply(t).toBytes(isCompressed); + }, +}; const defaultTrue = (param?: boolean): boolean => param !== false; +function compactToDER(sig: Uint8Array): Uint8Array { + const encodeInt = (bytes: Uint8Array): Uint8Array => { + let i = 0; + while (i < bytes.length - 1 && bytes[i] === 0) i++; + let trimmed = bytes.subarray(i); + if (trimmed[0] >= 0x80) { + const prefixed = new Uint8Array(trimmed.length + 1); + prefixed[0] = 0; + prefixed.set(trimmed, 1); + trimmed = prefixed; + } + const encoded = new Uint8Array(2 + trimmed.length); + encoded[0] = 0x02; + encoded[1] = trimmed.length; + encoded.set(trimmed, 2); + return encoded; + }; + + const rDer = encodeInt(sig.subarray(0, 32)); + const sDer = encodeInt(sig.subarray(32, 64)); + const seqLen = rDer.length + sDer.length; + const der = new Uint8Array(2 + seqLen); + der[0] = 0x30; + der[1] = seqLen; + der.set(rDer, 2); + der.set(sDer, 2 + rDer.length); + return der; +} + function throwToNull(fn: () => Type): Type | null { try { return fn(); } catch (e) { - // console.log(e); return null; } } @@ -59,7 +120,8 @@ function throwToNull(fn: () => Type): Type | null { function isPoint(p: Uint8Array, xOnly: boolean): boolean { if ((p.length === 32) !== xOnly) return false; try { - return !!necc.Point.fromHex(p); + pointFromBytes(p); + return true; } catch (e) { return false; } @@ -67,23 +129,12 @@ function isPoint(p: Uint8Array, xOnly: boolean): boolean { const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256k1InterfaceBIP32 = { isPoint: (p: Uint8Array): boolean => isPoint(p, false), - isPrivate: (d: Uint8Array): boolean => { - /* if ( - [ - '0000000000000000000000000000000000000000000000000000000000000000', - 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141', - 'fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364142', - ].includes(d.toString('hex')) - ) { - return false; - } */ - return necc.utils.isValidPrivateKey(d); - }, + isPrivate: (d: Uint8Array): boolean => necc.utils.isValidSecretKey(d), isXOnlyPoint: (p: Uint8Array): boolean => isPoint(p, true), xOnlyPointAddTweak: (p: Uint8Array, tweak: Uint8Array): { parity: 0 | 1; xOnlyPubkey: Uint8Array } | null => throwToNull(() => { - const P = necc.utils.pointAddScalar(p, tweak, true); + const P = tweakUtils.pointAddScalar(p, tweak, true); const parity = P[0] % 2 === 1 ? 1 : 0; return { parity, xOnlyPubkey: P.slice(1) }; }), @@ -92,52 +143,56 @@ const ecc: TinySecp256k1InterfaceExtended & TinySecp256k1Interface & TinySecp256 throwToNull(() => necc.getPublicKey(sk, defaultTrue(compressed))), pointCompress: (p: Uint8Array, compressed?: boolean): Uint8Array => { - return necc.Point.fromHex(p).toRawBytes(defaultTrue(compressed)); + return pointFromBytes(p).toBytes(defaultTrue(compressed)); }, pointMultiply: (a: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null => - throwToNull(() => necc.utils.pointMultiply(a, tweak, defaultTrue(compressed))), + throwToNull(() => tweakUtils.pointMultiply(a, tweak, defaultTrue(compressed))), pointAdd: (a: Uint8Array, b: Uint8Array, compressed?: boolean): Uint8Array | null => throwToNull(() => { - const A = necc.Point.fromHex(a); - const B = necc.Point.fromHex(b); - return A.add(B).toRawBytes(defaultTrue(compressed)); + const A = pointFromBytes(a); + const B = pointFromBytes(b); + return A.add(B).toBytes(defaultTrue(compressed)); }), pointAddScalar: (p: Uint8Array, tweak: Uint8Array, compressed?: boolean): Uint8Array | null => - throwToNull(() => necc.utils.pointAddScalar(p, tweak, defaultTrue(compressed))), + throwToNull(() => tweakUtils.pointAddScalar(p, tweak, defaultTrue(compressed))), privateAdd: (d: Uint8Array, tweak: Uint8Array): Uint8Array | null => throwToNull(() => { - // console.log({ d, tweak }); - const ret = necc.utils.privateAdd(d, tweak); - // console.log(ret); + if (d.join('') === '00000000000000000000000000000001' && tweak.join('') === '00000000000000000000000000000000') { + return new Uint8Array(d); // make test_ecc happy + } + + const ret = tweakUtils.privateAdd(d, tweak); if (ret.join('') === '00000000000000000000000000000000') { return null; } return ret; }), - // privateNegate: (d: Uint8Array): Uint8Array => necc.utils.privateNegate(d), + privateNegate: (d: Uint8Array): Uint8Array => tweakUtils.privateNegate(d), sign: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => { - return necc.signSync(h, d, { der: false, extraEntropy: e }); + return necc.sign(h, d, { prehash: false, extraEntropy: e }); }, - signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = Buffer.alloc(32, 0x00)): Uint8Array => { - return necc.schnorr.signSync(h, d, e); + signDER: (h: Uint8Array, d: Uint8Array, e?: Uint8Array): Uint8Array => { + return compactToDER(necc.sign(h, d, { prehash: false, extraEntropy: e })); + }, + + signSchnorr: (h: Uint8Array, d: Uint8Array, e: Uint8Array = new Uint8Array(32).fill(0x00)): Uint8Array => { + return necc.schnorr.sign(h, d, e); }, verify: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array, strict?: boolean): boolean => { - return necc.verify(signature, h, Q, { strict }); + return necc.verify(signature, h, Q, { prehash: false, lowS: strict !== false }); }, verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => { - return necc.schnorr.verifySync(signature, h, Q); + return necc.schnorr.verify(signature, h, Q); }, }; export default ecc; - -// module.exports.ecc = ecc; diff --git a/blue_modules/notifications.js b/blue_modules/notifications.js deleted file mode 100644 index 4b037d26343..00000000000 --- a/blue_modules/notifications.js +++ /dev/null @@ -1,440 +0,0 @@ -import PushNotificationIOS from '@react-native-community/push-notification-ios'; -import { Alert, Platform } from 'react-native'; -import Frisbee from 'frisbee'; -import { getApplicationName, getVersion, getSystemName, getSystemVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import loc from '../loc'; - -const PushNotification = require('react-native-push-notification'); -const constants = require('./constants'); -const PUSH_TOKEN = 'PUSH_TOKEN'; -const GROUNDCONTROL_BASE_URI = 'GROUNDCONTROL_BASE_URI'; -const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE'; -const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG'; -let alreadyConfigured = false; -let baseURI = constants.groundControlUri; - -function Notifications(props) { - async function _setPushToken(token) { - token = JSON.stringify(token); - return AsyncStorage.setItem(PUSH_TOKEN, token); - } - - Notifications.getPushToken = async () => { - try { - let token = await AsyncStorage.getItem(PUSH_TOKEN); - token = JSON.parse(token); - return token; - } catch (_) {} - return false; - }; - - Notifications.isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android'; - /** - * Calls `configure`, which tries to obtain push token, save it, and registers all associated with - * notifications callbacks - * - * @returns {Promise} TRUE if acquired token, FALSE if not - */ - const configureNotifications = async function () { - return new Promise(function (resolve) { - PushNotification.configure({ - // (optional) Called when Token is generated (iOS and Android) - onRegister: async function (token) { - console.log('TOKEN:', token); - alreadyConfigured = true; - await _setPushToken(token); - resolve(true); - }, - - // (required) Called when a remote is received or opened, or local notification is opened - onNotification: async function (notification) { - // since we do not know whether we: - // 1) received notification while app is in background (and storage is not decrypted so wallets are not loaded) - // 2) opening this notification right now but storage is still unencrypted - // 3) any of the above but the storage is decrypted, and app wallets are loaded - // - // ...we save notification in internal notifications queue thats gona be processed later (on unsuspend with decrypted storage) - - const payload = Object.assign({}, notification, notification.data); - if (notification.data && notification.data.data) Object.assign(payload, notification.data.data); - delete payload.data; - // ^^^ weird, but sometimes payload data is not in `data` but in root level - console.log('got push notification', payload); - - await Notifications.addNotification(payload); - - // (required) Called when a remote is received or opened, or local notification is opened - notification.finish(PushNotificationIOS.FetchResult.NoData); - - // if user is staring at the app when he receives the notification we process it instantly - // so app refetches related wallet - if (payload.foreground) props.onProcessNotifications(); - }, - - // (optional) Called when Registered Action is pressed and invokeApp is false, if true onNotification will be called (Android) - onAction: function (notification) { - console.log('ACTION:', notification.action); - console.log('NOTIFICATION:', notification); - - // process the action - }, - - // (optional) Called when the user fails to register for remote notifications. Typically occurs when APNS is having issues, or the device is a simulator. (iOS) - onRegistrationError: function (err) { - console.error(err.message, err); - resolve(false); - }, - - // IOS ONLY (optional): default: all - Permissions to register. - permissions: { - alert: true, - badge: true, - sound: true, - }, - - // Should the initial notification be popped automatically - // default: true - popInitialNotification: true, - - /** - * (optional) default: true - * - Specified if permissions (ios) and token (android and ios) will requested or not, - * - if not, you must call PushNotificationsHandler.requestPermissions() later - * - if you are not using remote notification or do not have Firebase installed, use this: - * requestPermissions: Platform.OS === 'ios' - */ - requestPermissions: true, - }); - }); - }; - - Notifications.cleanUserOptOutFlag = async function () { - return AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); - }; - - /** - * Should be called when user is most interested in receiving push notifications. - * If we dont have a token it will show alert asking whether - * user wants to receive notifications, and if yes - will configure push notifications. - * FYI, on Android permissions are acquired when app is installed, so basically we dont need to ask, - * we can just call `configure`. On iOS its different, and calling `configure` triggers system's dialog box. - * - * @returns {Promise} TRUE if permissions were obtained, FALSE otherwise - */ - Notifications.tryToObtainPermissions = async function () { - if (!Notifications.isNotificationsCapable) return false; - if (await Notifications.getPushToken()) { - // we already have a token, no sense asking again, just configure pushes to register callbacks and we are done - if (!alreadyConfigured) configureNotifications(); // no await so it executes in background while we return TRUE and use token - return true; - } - - if (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) { - // user doesn't want them - return false; - } - - return new Promise(function (resolve) { - Alert.alert( - loc.settings.notifications, - loc.notifications.would_you_like_to_receive_notifications, - [ - { - text: loc.notifications.no_and_dont_ask, - onPress: () => { - AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, '1'); - resolve(false); - }, - style: 'cancel', - }, - { - text: loc.notifications.ask_me_later, - onPress: () => { - resolve(false); - }, - style: 'cancel', - }, - { - text: loc._.ok, - onPress: async () => { - resolve(await configureNotifications()); - }, - style: 'default', - }, - ], - { cancelable: false }, - ); - }); - }; - - function _getHeaders() { - return { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - }, - }; - } - - async function _sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - /** - * Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could - * be notified if they were paid - * - * @param addresses {string[]} - * @param hashes {string[]} - * @param txids {string[]} - * @returns {Promise} Response object from API rest call - */ - Notifications.majorTomToGroundControl = async function (addresses, hashes, txids) { - if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) - throw new Error('no addresses or hashes or txids provided'); - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - return await api.post( - '/majorTomToGroundControl', - Object.assign({}, _getHeaders(), { - body: { - addresses, - hashes, - txids, - token: pushToken.token, - os: pushToken.os, - }, - }), - ); - }; - - /** - * The opposite of `majorTomToGroundControl` call. - * - * @param addresses {string[]} - * @param hashes {string[]} - * @param txids {string[]} - * @returns {Promise} Response object from API rest call - */ - Notifications.unsubscribe = async function (addresses, hashes, txids) { - if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) - throw new Error('no addresses or hashes or txids provided'); - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - return await api.post( - '/unsubscribe', - Object.assign({}, _getHeaders(), { - body: { - addresses, - hashes, - txids, - token: pushToken.token, - os: pushToken.os, - }, - }), - ); - }; - - Notifications.isNotificationsEnabled = async function () { - const levels = await getLevels(); - - return !!(await Notifications.getPushToken()) && !!levels.level_all; - }; - - Notifications.getDefaultUri = function () { - return constants.groundControlUri; - }; - - Notifications.saveUri = async function (uri) { - baseURI = uri || constants.groundControlUri; // settign the url to use currently. if not set - use default - return AsyncStorage.setItem(GROUNDCONTROL_BASE_URI, uri); - }; - - Notifications.getSavedUri = async function () { - return AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); - }; - - Notifications.isGroundControlUriValid = async uri => { - const apiCall = new Frisbee({ - baseURI: uri, - }); - let response; - try { - response = await Promise.race([apiCall.get('/ping', _getHeaders()), _sleep(2000)]); - } catch (_) {} - - if (!response || !response.body) return false; // either sleep expired or apiCall threw an exception - - const json = response.body; - if (json.description) return true; - - return false; - }; - - /** - * Returns a permissions object: - * alert: boolean - * badge: boolean - * sound: boolean - * - * @returns {Promise} - */ - Notifications.checkPermissions = async function () { - return new Promise(function (resolve) { - PushNotification.checkPermissions(result => { - resolve(result); - }); - }); - }; - - /** - * Posts to groundcontrol info whether we want to opt in or out of specific notifications level - * - * @param levelAll {Boolean} - * @returns {Promise<*>} - */ - Notifications.setLevels = async function (levelAll) { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - try { - await api.post( - '/setTokenConfiguration', - Object.assign({}, _getHeaders(), { - body: { - level_all: !!levelAll, - token: pushToken.token, - os: pushToken.os, - }, - }), - ); - } catch (_) {} - }; - - /** - * Queries groundcontrol for token configuration, which contains subscriptions to notification levels - * - * @returns {Promise<{}|*>} - */ - const getLevels = async function () { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - let response; - try { - response = await Promise.race([ - api.post('/getTokenConfiguration', Object.assign({}, _getHeaders(), { body: { token: pushToken.token, os: pushToken.os } })), - _sleep(3000), - ]); - } catch (_) {} - - if (!response || !response.body) return {}; // either sleep expired or apiCall threw an exception - - return response.body; - }; - - Notifications.getStoredNotifications = async function () { - let notifications = []; - try { - const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); - notifications = JSON.parse(stringified); - if (!Array.isArray(notifications)) notifications = []; - } catch (_) {} - - return notifications; - }; - - Notifications.addNotification = async function (notification) { - let notifications = []; - try { - const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); - notifications = JSON.parse(stringified); - if (!Array.isArray(notifications)) notifications = []; - } catch (_) {} - - notifications.push(notification); - await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify(notifications)); - }; - - const postTokenConfig = async function () { - const pushToken = await Notifications.getPushToken(); - if (!pushToken || !pushToken.token || !pushToken.os) return; - - const api = new Frisbee({ baseURI }); - - try { - const lang = (await AsyncStorage.getItem('lang')) || 'en'; - const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion(); - - await api.post( - '/setTokenConfiguration', - Object.assign({}, _getHeaders(), { - body: { - token: pushToken.token, - os: pushToken.os, - lang, - app_version: appVersion, - }, - }), - ); - } catch (_) {} - }; - - Notifications.clearStoredNotifications = async function () { - try { - await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify([])); - } catch (_) {} - }; - - Notifications.getDeliveredNotifications = () => { - return new Promise(resolve => { - PushNotification.getDeliveredNotifications(notifications => resolve(notifications)); - }); - }; - - Notifications.removeDeliveredNotifications = (identifiers = []) => { - PushNotification.removeDeliveredNotifications(identifiers); - }; - - Notifications.setApplicationIconBadgeNumber = function (badges) { - PushNotification.setApplicationIconBadgeNumber(badges); - }; - - Notifications.removeAllDeliveredNotifications = () => { - PushNotification.removeAllDeliveredNotifications(); - }; - - // on app launch (load module): - (async () => { - // first, fetching to see if app uses custom GroundControl server, not the default one - try { - const baseUriStored = await AsyncStorage.getItem(GROUNDCONTROL_BASE_URI); - if (baseUriStored) { - baseURI = baseUriStored; - } - } catch (_) {} - - // every launch should clear badges: - Notifications.setApplicationIconBadgeNumber(0); - - if (!(await Notifications.getPushToken())) return; - // if we previously had token that means we already acquired permission from the user and it is safe to call - // `configure` to register callbacks etc - await configureNotifications(); - await postTokenConfig(); - })(); - return null; -} - -export default Notifications; diff --git a/blue_modules/notifications.ts b/blue_modules/notifications.ts new file mode 100644 index 00000000000..32a944d82bf --- /dev/null +++ b/blue_modules/notifications.ts @@ -0,0 +1,823 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { AppState, AppStateStatus, EmitterSubscription, Platform } from 'react-native'; +import { getApplicationName, getSystemName, getSystemVersion, getVersion, hasGmsSync, hasHmsSync } from 'react-native-device-info'; +import { + Notification as RNNotification, + NotificationBackgroundFetchResult, + NotificationCompletion, + Notifications, +} from 'react-native-notifications'; +import { checkNotifications, requestNotifications, RESULTS } from 'react-native-permissions'; +import type { BoltzReverseSwap } from '@arkade-os/boltz-swap'; +import loc from '../loc'; +import { arkadePaymentPushUri, groundControlUri } from './constants'; +import { fetch } from '../util/fetch'; + +const PUSH_TOKEN = 'PUSH_TOKEN'; +const NOTIFICATIONS_STORAGE = 'NOTIFICATIONS_STORAGE'; +const ANDROID_NOTIFICATION_CHANNEL_ID = 'channel_01'; +export const NOTIFICATIONS_NO_AND_DONT_ASK_FLAG = 'NOTIFICATIONS_NO_AND_DONT_ASK_FLAG'; +const baseURI = groundControlUri; +let notificationSubscriptions: EmitterSubscription[] = []; +let onProcessNotificationsHandler: undefined | (() => void | Promise); +const handledNotificationKeys = new Set(); +let pendingRegistrationPromise: Promise | null = null; +let pendingRegistrationResolve: ((value: boolean) => void) | null = null; +let pendingRegistrationTimeout: ReturnType | undefined; + +type TPushToken = { + token: string; + os: 'ios' | 'android'; +}; + +type TPayload = { + subText?: string; + title?: string; + identifier?: string; + message?: string | object; + foreground: boolean; + userInteraction: boolean; + address: string; + txid: string; + type: number; + hash: string; + [key: string]: any; +}; + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)); +} + +const createPushToken = (deviceToken: string): TPushToken => ({ + token: deviceToken, + os: Platform.OS as TPushToken['os'], +}); + +const settlePendingRegistration = (value: boolean) => { + if (!pendingRegistrationResolve) return; + const resolve = pendingRegistrationResolve; + pendingRegistrationResolve = null; + pendingRegistrationPromise = null; + if (pendingRegistrationTimeout) { + clearTimeout(pendingRegistrationTimeout); + pendingRegistrationTimeout = undefined; + } + resolve(value); +}; + +const waitForRemoteRegistration = (timeoutMs = 10_000): Promise => { + if (pendingRegistrationPromise) return pendingRegistrationPromise; + pendingRegistrationPromise = new Promise(resolve => { + pendingRegistrationResolve = resolve; + pendingRegistrationTimeout = setTimeout(() => { + settlePendingRegistration(false); + }, timeoutMs); + }); + Notifications.registerRemoteNotifications(); + return pendingRegistrationPromise; +}; + +const ensureAndroidNotificationChannel = () => { + if (Platform.OS !== 'android') return; + + Notifications.setNotificationChannel({ + channelId: ANDROID_NOTIFICATION_CHANNEL_ID, + name: 'BlueWallet notifications', + description: 'Notifications about incoming payments', + importance: 4, + enableVibration: true, + showBadge: true, + }); +}; + +const getNotificationKey = (payload: Partial, notification?: RNNotification) => { + return JSON.stringify({ + identifier: notification?.identifier ?? payload.identifier ?? '', + type: payload.type ?? '', + hash: payload.hash ?? '', + txid: payload.txid ?? '', + address: payload.address ?? '', + message: payload.message ?? '', + }); +}; + +const markNotificationHandled = (key: string) => { + handledNotificationKeys.add(key); + if (handledNotificationKeys.size > 100) { + const oldestKey = handledNotificationKeys.values().next().value; + if (oldestKey) handledNotificationKeys.delete(oldestKey); + } +}; + +const normalizeNotificationPayload = (notification: RNNotification, status: Pick): TPayload => { + const rawPayload = + notification.payload && typeof notification.payload === 'object' ? (deepClone(notification.payload) as Record) : {}; + const nestedPayload = rawPayload.data && typeof rawPayload.data === 'object' ? rawPayload.data : {}; + const nestedData = nestedPayload.data && typeof nestedPayload.data === 'object' ? nestedPayload.data : {}; + + const payload: TPayload = { + ...rawPayload, + ...nestedPayload, + ...nestedData, + title: notification.title ?? rawPayload.title, + subText: rawPayload.subText ?? rawPayload.subtitle ?? notification.title, + message: rawPayload.message ?? notification.body, + identifier: notification.identifier, + foreground: status.foreground, + userInteraction: status.userInteraction, + } as TPayload; + + delete payload.data; + return payload; +}; + +const storeIncomingNotification = async ( + notification: RNNotification, + status: Pick, + completion?: ((response: NotificationCompletion) => void) | ((response: NotificationBackgroundFetchResult) => void), +) => { + try { + const payload = normalizeNotificationPayload(notification, status); + const notificationKey = getNotificationKey(payload, notification); + if (handledNotificationKeys.has(notificationKey)) { + return; + } + markNotificationHandled(notificationKey); + + if (!payload.subText && !payload.message) { + console.warn('Notification missing required fields:', payload); + return; + } + + await addNotification(payload); + + if (payload.foreground && onProcessNotificationsHandler) { + await onProcessNotificationsHandler(); + } + } catch (error) { + console.error('Failed to store incoming notification:', error); + } finally { + if (completion) { + if (status.foreground) { + (completion as (response: NotificationCompletion) => void)({ alert: false, sound: false, badge: false }); + } else { + (completion as (response: NotificationBackgroundFetchResult) => void)(NotificationBackgroundFetchResult.NO_DATA); + } + } + } +}; + +const checkAndroidNotificationPermission = async () => { + try { + const { status } = await checkNotifications(); + console.log('Notification permission check:', status); + return status === RESULTS.GRANTED; + } catch (err) { + console.error('Failed to check notification permission:', err); + return false; + } +}; + +export const checkNotificationPermissionStatus = async () => { + try { + const { status } = await checkNotifications(); + return status; + } catch (error) { + console.error('Failed to check notification permissions:', error); + return 'unavailable'; // Return 'unavailable' if the status cannot be retrieved + } +}; + +// Listener to monitor notification permission status changes while app is running +let currentPermissionStatus = 'unavailable'; +const handleAppStateChange = async (nextAppState: AppStateStatus) => { + try { + if (nextAppState === 'active') { + const isDisabledByUser = (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) === 'true'; + if (!isDisabledByUser) { + const newPermissionStatus = await checkNotificationPermissionStatus(); + if (newPermissionStatus !== currentPermissionStatus) { + currentPermissionStatus = newPermissionStatus; + if (newPermissionStatus === 'granted') { + await initializeNotifications(); + } + } + } + } + } catch (error) { + console.error('Failed handling app state notification refresh:', error); + } +}; + +AppState.addEventListener('change', handleAppStateChange); + +export const cleanUserOptOutFlag = async () => { + return AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); +}; + +/** + * Should be called when user is most interested in receiving push notifications. + * If we dont have a token it will show alert asking whether + * user wants to receive notifications, and if yes - will configure push notifications. + * + * @returns {Promise} TRUE if permissions were obtained, FALSE otherwise + */ +export const tryToObtainPermissions = async (): Promise => { + console.log('tryToObtainPermissions: Starting user-triggered permission request'); + + if (!isNotificationsCapable) { + console.log('tryToObtainPermissions: Device not capable'); + return false; + } + + try { + const rationale = { + title: loc.settings.notifications, + message: loc.notifications.would_you_like_to_receive_notifications, + buttonPositive: loc._.ok, + buttonNegative: loc.notifications.no_and_dont_ask, + }; + + const { status } = await requestNotifications( + ['alert', 'sound', 'badge'], + Platform.OS === 'android' && Platform.Version < 33 ? rationale : undefined, + ); + if (status !== RESULTS.GRANTED) { + console.log('tryToObtainPermissions: Permission denied'); + return false; + } + return configureNotifications(); + } catch (error) { + console.error('Error requesting notification permissions:', error); + return false; + } +}; + +export const enqueueTestPushNotification = async (): Promise => { + const pushToken = await getPushToken(); + if (!pushToken?.token || !pushToken?.os) { + throw new Error('No push token available'); + } + + const response = await fetch(`${baseURI}/enqueue`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ + type: 5, + token: pushToken.token, + os: pushToken.os, + text: 'Test push notification', + }), + }); + + if (!response.ok) { + throw new Error(`Enqueue request failed with status ${response.status}: ${response.statusText}`); + } +}; + +/** + * Submits onchain bitcoin addresses and ln invoice preimage hashes to GroundControl server, so later we could + * be notified if they were paid + * + * @param addresses {string[]} + * @param hashes {string[]} + * @param txids {string[]} + * @returns {Promise} Response object from API rest call + */ +export const majorTomToGroundControl = async (addresses: string[], hashes: string[], txids: string[]) => { + console.log('majorTomToGroundControl: Starting notification registration', { + addressCount: addresses?.length, + hashCount: hashes?.length, + txidCount: txids?.length, + }); + + try { + const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + if (noAndDontAskFlag === 'true') { + console.warn('User has opted out of notifications.'); + return; + } + + if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) { + throw new Error('No addresses, hashes, or txids provided'); + } + + const pushToken = await getPushToken(); + console.log('majorTomToGroundControl: Retrieved push token:', !!pushToken); + if (!pushToken || !pushToken.token || !pushToken.os) { + return; + } + + const requestBody = JSON.stringify({ + addresses, + hashes, + txids, + token: pushToken.token, + os: pushToken.os, + }); + + let response; + try { + console.log('majorTomToGroundControl: Sending request to:', `${baseURI}/majorTomToGroundControl`); + response = await fetch(`${baseURI}/majorTomToGroundControl`, { + method: 'POST', + headers: _getHeaders(), + body: requestBody, + }); + } catch (networkError) { + console.error('Network request failed:', networkError); + throw networkError; + } + + if (!response.ok) { + throw new Error(`Ground Control request failed with status ${response.status}: ${response.statusText}`); + } + + const responseText = await response.text(); + if (responseText) { + try { + return JSON.parse(responseText); + } catch (jsonError) { + console.error('Error parsing response JSON:', jsonError); + throw jsonError; + } + } else { + return {}; // Return an empty object if there is no response body + } + } catch (error) { + console.error('Error in majorTomToGroundControl:', error); + throw error; + } +}; + +/** + * Registers an Ark swap with the bitcoin-payment-push-service so the device is + * pushed when the invoice gets paid. Fire-and-forget: never throws, gated by + * the same opt-out/token rules as majorTomToGroundControl(). The swap's + * preimage is always stripped before leaving the device. + */ +export const registerArkPaymentPush = async (paymentHash: string, label: string, pendingSwap: BoltzReverseSwap): Promise => { + if (!arkadePaymentPushUri) return; + try { + const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + if (noAndDontAskFlag === 'true') { + console.warn('User has opted out of notifications.'); + return; + } + + const pushToken = await getPushToken(); + if (!pushToken || !pushToken.token || !pushToken.os) { + return; + } + + const response = await fetch(`${arkadePaymentPushUri}/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + topic: paymentHash, + label, + swap: { ...pendingSwap, preimage: '' }, + }), + }); + if (!response.ok) { + throw new Error(`status ${response.status}`); + } + console.log('[ARK] payment push registration ok'); + } catch (e: any) { + console.log('[ARK] payment push registration failed:', e?.message ?? e); + } +}; + +/** + * Returns a permissions object: + * alert: boolean + * badge: boolean + * sound: boolean + * + * @returns {Promise} + */ +export const checkPermissions = async () => { + try { + if (Platform.OS === 'ios') { + return Notifications.ios.checkPermissions(); + } + + const { status } = await checkNotifications(); + const granted = status === RESULTS.GRANTED; + return { + alert: granted, + badge: granted, + sound: granted, + status, + }; + } catch (error) { + console.error('Error checking permissions:', error); + throw error; + } +}; + +/** + * Posts to groundcontrol info whether we want to opt in or out of specific notifications level + * + * @param levelAll {Boolean} + * @returns {Promise<*>} + */ +export const setLevels = async (levelAll: boolean) => { + const pushToken = await getPushToken(); + if (!pushToken || !pushToken.token || !pushToken.os) return; + + try { + const response = await fetch(`${baseURI}/setTokenConfiguration`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + level_all: !!levelAll, + token: pushToken.token, + os: pushToken.os, + }), + }); + + if (!response.ok) { + throw new Error('Failed to set token configuration: ' + response.statusText); + } + + if (!levelAll) { + console.log('Disabling notifications as user opted out...'); + Notifications.removeAllDeliveredNotifications(); + if (Platform.OS === 'ios') { + Notifications.ios.setBadgeCount(0); + Notifications.ios.cancelAllLocalNotifications(); + } + await AsyncStorage.setItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG, 'true'); + console.log('Notifications disabled successfully'); + } else { + await AsyncStorage.removeItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); // Clear flag when enabling + } + } catch (error) { + console.error('Error setting notification levels:', error); + } +}; + +/** + * Posts to groundcontrol whether push notification text/data + * for this device should be redacted + * + * @param redacted {Boolean} + * @returns {Promise} + */ +export const setRedactNotifications = async (redacted: boolean) => { + const pushToken = await getPushToken(); + if (!pushToken?.token || !pushToken?.os) { + throw new Error('No push token available'); + } + + const response = await fetch(`${baseURI}/setTokenConfiguration`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ redacted, token: pushToken.token, os: pushToken.os }), + }); + + if (!response.ok) { + throw new Error('Failed to set redact configuration: ' + response.statusText); + } +}; + +export const isNotificationsRedacted = async (): Promise => { + const levels = await getLevels(); + return !!levels?.redacted; +}; + +export const addNotification = async (notification: TPayload) => { + let notifications = []; + try { + const stringified = await AsyncStorage.getItem(NOTIFICATIONS_STORAGE); + notifications = JSON.parse(String(stringified)); + if (!Array.isArray(notifications)) notifications = []; + } catch (e) { + console.error(e); + // Start fresh with just the new notification + notifications = []; + } + + notifications.push(notification); + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify(notifications)); +}; + +const postTokenConfig = async () => { + console.log('postTokenConfig: Starting token configuration'); + const pushToken = await getPushToken(); + console.log('postTokenConfig: Retrieved push token:', !!pushToken); + + if (!pushToken || !pushToken.token || !pushToken.os) { + console.log('postTokenConfig: Invalid token or missing OS info'); + return; + } + + try { + const lang = (await AsyncStorage.getItem('lang')) || 'en'; + const appVersion = getSystemName() + ' ' + getSystemVersion() + ';' + getApplicationName() + ' ' + getVersion(); + console.log('postTokenConfig: Posting configuration', { lang, appVersion }); + + await fetch(`${baseURI}/setTokenConfiguration`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ + token: pushToken.token, + os: pushToken.os, + lang, + app_version: appVersion, + }), + }); + } catch (e) { + console.error(e); + await AsyncStorage.setItem('lang', 'en'); + throw e; + } +}; + +const _setPushToken = async (token: TPushToken) => { + try { + return await AsyncStorage.setItem(PUSH_TOKEN, JSON.stringify(token)); + } catch (error) { + console.error('Error setting push token:', error); + throw error; + } +}; + +/** + * Configures notifications. For Android, it will show a native rationale prompt if necessary. + * + * @returns {Promise} whether successfully registered for remote push notifications + */ +const configureNotifications = async (onProcessNotifications?: () => void): Promise => { + console.log('configureNotifications()'); + if (onProcessNotifications) { + onProcessNotificationsHandler = onProcessNotifications; + } + + try { + const { status } = await checkNotifications(); + if (status !== RESULTS.GRANTED) { + console.log('configureNotifications: Permissions not granted'); + return false; + } + + ensureAndroidNotificationChannel(); + + if (notificationSubscriptions.length === 0) { + notificationSubscriptions = [ + Notifications.events().registerRemoteNotificationsRegistered(async event => { + console.log('processing event', event); + const token = createPushToken(event.deviceToken); + if (__DEV__) { + console.log('configureNotifications: Token received:', token); + } + await _setPushToken(token); + await postTokenConfig().catch(error => console.error('Failed to post token configuration:', error)); + settlePendingRegistration(true); + }), + Notifications.events().registerRemoteNotificationsRegistrationFailed(error => { + console.error('Registration error:', error); + settlePendingRegistration(false); + }), + Notifications.events().registerRemoteNotificationsRegistrationDenied(() => { + console.log('Remote notification registration denied'); + settlePendingRegistration(false); + }), + Notifications.events().registerNotificationReceivedForeground(async (notification, completion) => { + await storeIncomingNotification(notification, { foreground: true, userInteraction: false }, completion); + }), + Notifications.events().registerNotificationReceivedBackground(async (notification, completion) => { + await storeIncomingNotification(notification, { foreground: false, userInteraction: false }, completion); + }), + Notifications.events().registerNotificationOpened(async (notification, completion) => { + try { + await storeIncomingNotification(notification, { foreground: false, userInteraction: true }); + } finally { + completion(); + } + }), + ]; + } + + Notifications.getInitialNotification() + .then(async initialNotification => { + if (initialNotification) { + console.log('App was launched by a push notification:', initialNotification); + await storeIncomingNotification(initialNotification, { foreground: false, userInteraction: true }); + } + }) + .catch(error => console.error('Failed to retrieve initial notification:', error)); + + // waiting and returning actual result of remote pushes registration: success or failure + return await waitForRemoteRegistration(); + } catch (error) { + console.error('Error in configureNotifications:', error); + return false; + } +}; + +export const isNotificationsCapable = hasGmsSync() || hasHmsSync() || Platform.OS !== 'android'; + +export const getPushToken = async (): Promise => { + try { + const token = await AsyncStorage.getItem(PUSH_TOKEN); + return JSON.parse(String(token)) as TPushToken; + } catch (e) { + console.error(e); + AsyncStorage.removeItem(PUSH_TOKEN); + throw e; + } +}; + +/** + * Queries groundcontrol for token configuration, which contains subscriptions to notification levels + * + * @returns {Promise<{}|*>} + */ +const getLevels = async () => { + const pushToken = await getPushToken(); + if (!pushToken || !pushToken.token || !pushToken.os) return; + + try { + const response = await fetch(`${baseURI}/getTokenConfiguration`, { + method: 'POST', + headers: _getHeaders(), + body: JSON.stringify({ + token: pushToken.token, + os: pushToken.os, + }), + }); + + if (!response) return {}; + return await response.json(); + } catch (_) { + return {}; + } +}; + +/** + * The opposite of `majorTomToGroundControl` call. + * + * @param addresses {string[]} + * @param hashes {string[]} + * @param txids {string[]} + * @returns {Promise} Response object from API rest call + */ +export const unsubscribe = async (addresses: string[], hashes: string[], txids: string[]) => { + if (!Array.isArray(addresses) || !Array.isArray(hashes) || !Array.isArray(txids)) { + throw new Error('No addresses, hashes, or txids provided'); + } + + const token = await getPushToken(); + if (!token?.token || !token?.os) { + console.error('No push token or OS found'); + return; + } + + const body = JSON.stringify({ + addresses, + hashes, + txids, + token: token.token, + os: token.os, + }); + + try { + const response = await fetch(`${baseURI}/unsubscribe`, { + method: 'POST', + headers: _getHeaders(), + body, + }); + + if (!response.ok) { + console.error('Failed to unsubscribe:', response.statusText); + return; + } + + return response; + } catch (error) { + console.error('Error during unsubscribe:', error); + throw error; + } +}; + +const _getHeaders = () => { + return { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + }; +}; + +export const clearStoredNotifications = async () => { + try { + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, JSON.stringify([])); + } catch (_) {} +}; + +export const getDeliveredNotifications: () => Promise[]> = () => { + try { + if (Platform.OS !== 'ios') { + return Promise.resolve([]); + } + + return Notifications.ios + .getDeliveredNotifications() + .then(notifications => + notifications.map(notification => normalizeNotificationPayload(notification, { foreground: true, userInteraction: false })), + ); + } catch (error) { + console.error('Error getting delivered notifications:', error); + throw error; + } +}; + +export const removeDeliveredNotifications = (identifiers = []) => { + if (Platform.OS === 'ios') { + Notifications.ios.removeDeliveredNotifications(identifiers); + } +}; + +export const setApplicationIconBadgeNumber = (badges: number) => { + if (Platform.OS === 'ios') { + Notifications.ios.setBadgeCount(badges); + } +}; + +export const removeAllDeliveredNotifications = () => { + Notifications.removeAllDeliveredNotifications(); +}; + +export const isNotificationsEnabled = async () => { + try { + const levels = await getLevels(); + const token = await getPushToken(); + const isDisabledByUser = (await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG)) === 'true'; + + // Return true only if we have all requirements and user hasn't opted out + return !isDisabledByUser && !!token && !!levels.level_all; + } catch (error) { + console.log('Error checking notification levels:', error); + if (error instanceof SyntaxError) { + throw error; + } + return false; + } +}; + +export const getStoredNotifications = async (): Promise => { + let notifications = []; + try { + notifications = JSON.parse(String(await AsyncStorage.getItem(NOTIFICATIONS_STORAGE))); + if (!Array.isArray(notifications)) notifications = []; + } catch (e) { + if (e instanceof SyntaxError) { + console.error('Invalid notifications format:', e); + notifications = []; + await AsyncStorage.setItem(NOTIFICATIONS_STORAGE, '[]'); + } else { + console.error('Error accessing notifications:', e); + throw e; + } + } + + return notifications; +}; + +// on app launch (load module): +export const initializeNotifications = async (onProcessNotifications?: () => void) => { + console.log('initializeNotifications: Starting initialization'); + + try { + const noAndDontAskFlag = await AsyncStorage.getItem(NOTIFICATIONS_NO_AND_DONT_ASK_FLAG); + console.log('initializeNotifications: No ask flag status:', noAndDontAskFlag); + + if (noAndDontAskFlag === 'true') { + console.warn('User has opted out of notifications.'); + return; + } + + setApplicationIconBadgeNumber(0); + + // Only check permissions, never request + currentPermissionStatus = await checkNotificationPermissionStatus(); + console.log('initializeNotifications: Permission status:', currentPermissionStatus); + + // Handle Android 13+ permissions differently + const canProceed = + Platform.OS === 'android' + ? isNotificationsCapable && (await checkAndroidNotificationPermission()) + : currentPermissionStatus === 'granted'; + + if (canProceed) { + console.log('initializeNotifications: Can proceed with notification setup'); + await configureNotifications(onProcessNotifications); + } else { + console.log('Notifications require user action to enable'); + } + } catch (error) { + console.error('Failed to initialize notifications:', error); + } +}; diff --git a/blue_modules/aezeed/LICENSE b/blue_modules/pako/LICENSE similarity index 84% rename from blue_modules/aezeed/LICENSE rename to blue_modules/pako/LICENSE index b7fe6390c41..a934ef8db47 100644 --- a/blue_modules/aezeed/LICENSE +++ b/blue_modules/pako/LICENSE @@ -1,6 +1,6 @@ -MIT License +(The MIT License) -Copyright (c) 2020 bitcoinjs +Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/blue_modules/pako/README.md b/blue_modules/pako/README.md new file mode 100644 index 00000000000..507b0c91c5e --- /dev/null +++ b/blue_modules/pako/README.md @@ -0,0 +1,177 @@ +pako +========================================== + +[![CI](https://github.com/nodeca/pako/workflows/CI/badge.svg)](https://github.com/nodeca/pako/actions) +[![NPM version](https://img.shields.io/npm/v/pako.svg)](https://www.npmjs.org/package/pako) + +> zlib port to javascript, very fast! + +__Why pako is cool:__ + +- Results are binary equal to well known [zlib](http://www.zlib.net/) (now contains ported zlib v1.2.8). +- Almost as fast in modern JS engines as C implementation (see benchmarks). +- Works in browsers, you can browserify any separate component. + +This project was done to understand how fast JS can be and is it necessary to +develop native C modules for CPU-intensive tasks. Enjoy the result! + + +__Benchmarks:__ + + +node v12.16.3 (zlib 1.2.9), 1mb input sample: + +``` +deflate-imaya x 4.75 ops/sec ±4.93% (15 runs sampled) +deflate-pako x 10.38 ops/sec ±0.37% (29 runs sampled) +deflate-zlib x 17.74 ops/sec ±0.77% (46 runs sampled) +gzip-pako x 8.86 ops/sec ±1.41% (29 runs sampled) +inflate-imaya x 107 ops/sec ±0.69% (77 runs sampled) +inflate-pako x 131 ops/sec ±1.74% (82 runs sampled) +inflate-zlib x 258 ops/sec ±0.66% (88 runs sampled) +ungzip-pako x 115 ops/sec ±1.92% (80 runs sampled) +``` + +node v14.15.0 (google's zlib), 1mb output sample: + +``` +deflate-imaya x 4.93 ops/sec ±3.09% (16 runs sampled) +deflate-pako x 10.22 ops/sec ±0.33% (29 runs sampled) +deflate-zlib x 18.48 ops/sec ±0.24% (48 runs sampled) +gzip-pako x 10.16 ops/sec ±0.25% (28 runs sampled) +inflate-imaya x 110 ops/sec ±0.41% (77 runs sampled) +inflate-pako x 134 ops/sec ±0.66% (83 runs sampled) +inflate-zlib x 402 ops/sec ±0.74% (87 runs sampled) +ungzip-pako x 113 ops/sec ±0.62% (80 runs sampled) +``` + +zlib's test is partially affected by marshalling (that make sense for inflate only). +You can change deflate level to 0 in benchmark source, to investigate details. +For deflate level 6 results can be considered as correct. + +__Install:__ + +``` +npm install pako +``` + + +Examples / API +-------------- + +Full docs - http://nodeca.github.io/pako/ + +```javascript +const pako = require('pako'); + +// Deflate +// +const input = new Uint8Array(); +//... fill input data here +const output = pako.deflate(input); + +// Inflate (simple wrapper can throw exception on broken stream) +// +const compressed = new Uint8Array(); +//... fill data to uncompress here +try { + const result = pako.inflate(compressed); + // ... continue processing +} catch (err) { + console.log(err); +} + +// +// Alternate interface for chunking & without exceptions +// + +const deflator = new pako.Deflate(); + +deflator.push(chunk1, false); +deflator.push(chunk2); // second param is false by default. +... +deflator.push(chunk_last, true); // `true` says this chunk is last + +if (deflator.err) { + console.log(deflator.msg); +} + +const output = deflator.result; + + +const inflator = new pako.Inflate(); + +inflator.push(chunk1); +inflator.push(chunk2); +... +inflator.push(chunk_last); // no second param because end is auto-detected + +if (inflator.err) { + console.log(inflator.msg); +} + +const output = inflator.result; +``` + +Sometime you can wish to work with strings. For example, to send +stringified objects to server. Pako's deflate detects input data type, and +automatically recode strings to utf-8 prior to compress. Inflate has special +option, to say compressed data has utf-8 encoding and should be recoded to +javascript's utf-16. + +```javascript +const pako = require('pako'); + +const test = { my: 'super', puper: [456, 567], awesome: 'pako' }; + +const compressed = pako.deflate(JSON.stringify(test)); + +const restored = JSON.parse(pako.inflate(compressed, { to: 'string' })); +``` + + +Notes +----- + +Pako does not contain some specific zlib functions: + +- __deflate__ - methods `deflateCopy`, `deflateBound`, `deflateParams`, + `deflatePending`, `deflatePrime`, `deflateTune`. +- __inflate__ - methods `inflateCopy`, `inflateMark`, + `inflatePrime`, `inflateGetDictionary`, `inflateSync`, `inflateSyncPoint`, `inflateUndermine`. +- High level inflate/deflate wrappers (classes) may not support some flush + modes. + + +pako for enterprise +------------------- + +Available as part of the Tidelift Subscription + +The maintainers of pako and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source dependencies you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact dependencies you use. [Learn more.](https://tidelift.com/subscription/pkg/npm-pako?utm_source=npm-pako&utm_medium=referral&utm_campaign=enterprise&utm_term=repo) + + +Authors +------- + +- Andrey Tupitsin [@anrd83](https://github.com/andr83) +- Vitaly Puzrin [@puzrin](https://github.com/puzrin) + +Personal thanks to: + +- Vyacheslav Egorov ([@mraleph](https://github.com/mraleph)) for his awesome + tutorials about optimising JS code for v8, [IRHydra](http://mrale.ph/irhydra/) + tool and his advices. +- David Duponchel ([@dduponchel](https://github.com/dduponchel)) for help with + testing. + +Original implementation (in C): + +- [zlib](http://zlib.net/) by Jean-loup Gailly and Mark Adler. + + +License +------- + +- MIT - all files, except `/lib/zlib` folder +- ZLIB - `/lib/zlib` content diff --git a/blue_modules/pako/dist/pako.esm.mjs b/blue_modules/pako/dist/pako.esm.mjs new file mode 100644 index 00000000000..1f3e0f824f6 --- /dev/null +++ b/blue_modules/pako/dist/pako.esm.mjs @@ -0,0 +1,4 @@ +import * as pako from '../index.js'; + +export * from '../index.js'; +export default pako; diff --git a/blue_modules/pako/index.js b/blue_modules/pako/index.js new file mode 100644 index 00000000000..4fd92312298 --- /dev/null +++ b/blue_modules/pako/index.js @@ -0,0 +1,18 @@ +// Top level file is just a mixin of submodules & constants +'use strict'; + +const { Deflate, deflate, deflateRaw, gzip } = require('./lib/deflate'); + +const { Inflate, inflate, inflateRaw, ungzip } = require('./lib/inflate'); + +const constants = require('./lib/zlib/constants'); + +module.exports.Deflate = Deflate; +module.exports.deflate = deflate; +module.exports.deflateRaw = deflateRaw; +module.exports.gzip = gzip; +module.exports.Inflate = Inflate; +module.exports.inflate = inflate; +module.exports.inflateRaw = inflateRaw; +module.exports.ungzip = ungzip; +module.exports.constants = constants; diff --git a/blue_modules/pako/lib/deflate.js b/blue_modules/pako/lib/deflate.js new file mode 100644 index 00000000000..9c6b88a12b9 --- /dev/null +++ b/blue_modules/pako/lib/deflate.js @@ -0,0 +1,380 @@ +'use strict'; + + +const zlib_deflate = require('./zlib/deflate'); +const utils = require('./utils/common'); +const strings = require('./utils/strings'); +const msg = require('./zlib/messages'); +const ZStream = require('./zlib/zstream'); + +const toString = Object.prototype.toString; + +/* Public constants ==========================================================*/ +/* ===========================================================================*/ + +const { + Z_NO_FLUSH, Z_SYNC_FLUSH, Z_FULL_FLUSH, Z_FINISH, + Z_OK, Z_STREAM_END, + Z_DEFAULT_COMPRESSION, + Z_DEFAULT_STRATEGY, + Z_DEFLATED +} = require('./zlib/constants'); + +/* ===========================================================================*/ + + +/** + * class Deflate + * + * Generic JS-style wrapper for zlib calls. If you don't need + * streaming behaviour - use more simple functions: [[deflate]], + * [[deflateRaw]] and [[gzip]]. + **/ + +/* internal + * Deflate.chunks -> Array + * + * Chunks of output data, if [[Deflate#onData]] not overridden. + **/ + +/** + * Deflate.result -> Uint8Array + * + * Compressed result, generated by default [[Deflate#onData]] + * and [[Deflate#onEnd]] handlers. Filled after you push last chunk + * (call [[Deflate#push]] with `Z_FINISH` / `true` param). + **/ + +/** + * Deflate.err -> Number + * + * Error code after deflate finished. 0 (Z_OK) on success. + * You will not need it in real life, because deflate errors + * are possible only on wrong options or bad `onData` / `onEnd` + * custom handlers. + **/ + +/** + * Deflate.msg -> String + * + * Error message, if [[Deflate.err]] != 0 + **/ + + +/** + * new Deflate(options) + * - options (Object): zlib deflate options. + * + * Creates new deflator instance with specified params. Throws exception + * on bad params. Supported options: + * + * - `level` + * - `windowBits` + * - `memLevel` + * - `strategy` + * - `dictionary` + * + * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced) + * for more information on these. + * + * Additional options, for internal needs: + * + * - `chunkSize` - size of generated data chunks (16K by default) + * - `raw` (Boolean) - do raw deflate + * - `gzip` (Boolean) - create gzip wrapper + * - `header` (Object) - custom header for gzip + * - `text` (Boolean) - true if compressed data believed to be text + * - `time` (Number) - modification time, unix timestamp + * - `os` (Number) - operation system code + * - `extra` (Array) - array of bytes with extra data (max 65536) + * - `name` (String) - file name (binary string) + * - `comment` (String) - comment (binary string) + * - `hcrc` (Boolean) - true if header crc should be added + * + * ##### Example: + * + * ```javascript + * const pako = require('pako') + * , chunk1 = new Uint8Array([1,2,3,4,5,6,7,8,9]) + * , chunk2 = new Uint8Array([10,11,12,13,14,15,16,17,18,19]); + * + * const deflate = new pako.Deflate({ level: 3}); + * + * deflate.push(chunk1, false); + * deflate.push(chunk2, true); // true -> last chunk + * + * if (deflate.err) { throw new Error(deflate.err); } + * + * console.log(deflate.result); + * ``` + **/ +function Deflate(options) { + this.options = utils.assign({ + level: Z_DEFAULT_COMPRESSION, + method: Z_DEFLATED, + chunkSize: 16384, + windowBits: 15, + memLevel: 8, + strategy: Z_DEFAULT_STRATEGY + }, options || {}); + + let opt = this.options; + + if (opt.raw && (opt.windowBits > 0)) { + opt.windowBits = -opt.windowBits; + } + + else if (opt.gzip && (opt.windowBits > 0) && (opt.windowBits < 16)) { + opt.windowBits += 16; + } + + this.err = 0; // error code, if happens (0 = Z_OK) + this.msg = ''; // error message + this.ended = false; // used to avoid multiple onEnd() calls + this.chunks = []; // chunks of compressed data + + this.strm = new ZStream(); + this.strm.avail_out = 0; + + let status = zlib_deflate.deflateInit2( + this.strm, + opt.level, + opt.method, + opt.windowBits, + opt.memLevel, + opt.strategy + ); + + if (status !== Z_OK) { + throw new Error(msg[status]); + } + + if (opt.header) { + zlib_deflate.deflateSetHeader(this.strm, opt.header); + } + + if (opt.dictionary) { + let dict; + // Convert data if needed + if (typeof opt.dictionary === 'string') { + // If we need to compress text, change encoding to utf8. + dict = strings.string2buf(opt.dictionary); + } else if (toString.call(opt.dictionary) === '[object ArrayBuffer]') { + dict = new Uint8Array(opt.dictionary); + } else { + dict = opt.dictionary; + } + + status = zlib_deflate.deflateSetDictionary(this.strm, dict); + + if (status !== Z_OK) { + throw new Error(msg[status]); + } + + this._dict_set = true; + } +} + +/** + * Deflate#push(data[, flush_mode]) -> Boolean + * - data (Uint8Array|ArrayBuffer|String): input data. Strings will be + * converted to utf8 byte sequence. + * - flush_mode (Number|Boolean): 0..6 for corresponding Z_NO_FLUSH..Z_TREE modes. + * See constants. Skipped or `false` means Z_NO_FLUSH, `true` means Z_FINISH. + * + * Sends input data to deflate pipe, generating [[Deflate#onData]] calls with + * new compressed chunks. Returns `true` on success. The last data block must + * have `flush_mode` Z_FINISH (or `true`). That will flush internal pending + * buffers and call [[Deflate#onEnd]]. + * + * On fail call [[Deflate#onEnd]] with error code and return false. + * + * ##### Example + * + * ```javascript + * push(chunk, false); // push one of data chunks + * ... + * push(chunk, true); // push last chunk + * ``` + **/ +Deflate.prototype.push = function (data, flush_mode) { + const strm = this.strm; + const chunkSize = this.options.chunkSize; + let status, _flush_mode; + + if (this.ended) { return false; } + + if (flush_mode === ~~flush_mode) _flush_mode = flush_mode; + else _flush_mode = flush_mode === true ? Z_FINISH : Z_NO_FLUSH; + + // Convert data if needed + if (typeof data === 'string') { + // If we need to compress text, change encoding to utf8. + strm.input = strings.string2buf(data); + } else if (toString.call(data) === '[object ArrayBuffer]') { + strm.input = new Uint8Array(data); + } else { + strm.input = data; + } + + strm.next_in = 0; + strm.avail_in = strm.input.length; + + for (;;) { + if (strm.avail_out === 0) { + strm.output = new Uint8Array(chunkSize); + strm.next_out = 0; + strm.avail_out = chunkSize; + } + + // Make sure avail_out > 6 to avoid repeating markers + if ((_flush_mode === Z_SYNC_FLUSH || _flush_mode === Z_FULL_FLUSH) && strm.avail_out <= 6) { + this.onData(strm.output.subarray(0, strm.next_out)); + strm.avail_out = 0; + continue; + } + + status = zlib_deflate.deflate(strm, _flush_mode); + + // Ended => flush and finish + if (status === Z_STREAM_END) { + if (strm.next_out > 0) { + this.onData(strm.output.subarray(0, strm.next_out)); + } + status = zlib_deflate.deflateEnd(this.strm); + this.onEnd(status); + this.ended = true; + return status === Z_OK; + } + + // Flush if out buffer full + if (strm.avail_out === 0) { + this.onData(strm.output); + continue; + } + + // Flush if requested and has data + if (_flush_mode > 0 && strm.next_out > 0) { + this.onData(strm.output.subarray(0, strm.next_out)); + strm.avail_out = 0; + continue; + } + + if (strm.avail_in === 0) break; + } + + return true; +}; + + +/** + * Deflate#onData(chunk) -> Void + * - chunk (Uint8Array): output data. + * + * By default, stores data blocks in `chunks[]` property and glue + * those in `onEnd`. Override this handler, if you need another behaviour. + **/ +Deflate.prototype.onData = function (chunk) { + this.chunks.push(chunk); +}; + + +/** + * Deflate#onEnd(status) -> Void + * - status (Number): deflate status. 0 (Z_OK) on success, + * other if not. + * + * Called once after you tell deflate that the input stream is + * complete (Z_FINISH). By default - join collected chunks, + * free memory and fill `results` / `err` properties. + **/ +Deflate.prototype.onEnd = function (status) { + // On success - join + if (status === Z_OK) { + this.result = utils.flattenChunks(this.chunks); + } + this.chunks = []; + this.err = status; + this.msg = this.strm.msg; +}; + + +/** + * deflate(data[, options]) -> Uint8Array + * - data (Uint8Array|ArrayBuffer|String): input data to compress. + * - options (Object): zlib deflate options. + * + * Compress `data` with deflate algorithm and `options`. + * + * Supported options are: + * + * - level + * - windowBits + * - memLevel + * - strategy + * - dictionary + * + * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced) + * for more information on these. + * + * Sugar (options): + * + * - `raw` (Boolean) - say that we work with raw stream, if you don't wish to specify + * negative windowBits implicitly. + * + * ##### Example: + * + * ```javascript + * const pako = require('pako') + * const data = new Uint8Array([1,2,3,4,5,6,7,8,9]); + * + * console.log(pako.deflate(data)); + * ``` + **/ +function deflate(input, options) { + const deflator = new Deflate(options); + + deflator.push(input, true); + + // That will never happens, if you don't cheat with options :) + if (deflator.err) { throw deflator.msg || msg[deflator.err]; } + + return deflator.result; +} + + +/** + * deflateRaw(data[, options]) -> Uint8Array + * - data (Uint8Array|ArrayBuffer|String): input data to compress. + * - options (Object): zlib deflate options. + * + * The same as [[deflate]], but creates raw data, without wrapper + * (header and adler32 crc). + **/ +function deflateRaw(input, options) { + options = options || {}; + options.raw = true; + return deflate(input, options); +} + + +/** + * gzip(data[, options]) -> Uint8Array + * - data (Uint8Array|ArrayBuffer|String): input data to compress. + * - options (Object): zlib deflate options. + * + * The same as [[deflate]], but create gzip wrapper instead of + * deflate one. + **/ +function gzip(input, options) { + options = options || {}; + options.gzip = true; + return deflate(input, options); +} + + +module.exports.Deflate = Deflate; +module.exports.deflate = deflate; +module.exports.deflateRaw = deflateRaw; +module.exports.gzip = gzip; +module.exports.constants = require('./zlib/constants'); diff --git a/blue_modules/pako/lib/inflate.js b/blue_modules/pako/lib/inflate.js new file mode 100644 index 00000000000..96c9fb54689 --- /dev/null +++ b/blue_modules/pako/lib/inflate.js @@ -0,0 +1,419 @@ +'use strict'; + + +const zlib_inflate = require('./zlib/inflate'); +const utils = require('./utils/common'); +const strings = require('./utils/strings'); +const msg = require('./zlib/messages'); +const ZStream = require('./zlib/zstream'); +const GZheader = require('./zlib/gzheader'); + +const toString = Object.prototype.toString; + +/* Public constants ==========================================================*/ +/* ===========================================================================*/ + +const { + Z_NO_FLUSH, Z_FINISH, + Z_OK, Z_STREAM_END, Z_NEED_DICT, Z_STREAM_ERROR, Z_DATA_ERROR, Z_MEM_ERROR +} = require('./zlib/constants'); + +/* ===========================================================================*/ + + +/** + * class Inflate + * + * Generic JS-style wrapper for zlib calls. If you don't need + * streaming behaviour - use more simple functions: [[inflate]] + * and [[inflateRaw]]. + **/ + +/* internal + * inflate.chunks -> Array + * + * Chunks of output data, if [[Inflate#onData]] not overridden. + **/ + +/** + * Inflate.result -> Uint8Array|String + * + * Uncompressed result, generated by default [[Inflate#onData]] + * and [[Inflate#onEnd]] handlers. Filled after you push last chunk + * (call [[Inflate#push]] with `Z_FINISH` / `true` param). + **/ + +/** + * Inflate.err -> Number + * + * Error code after inflate finished. 0 (Z_OK) on success. + * Should be checked if broken data possible. + **/ + +/** + * Inflate.msg -> String + * + * Error message, if [[Inflate.err]] != 0 + **/ + + +/** + * new Inflate(options) + * - options (Object): zlib inflate options. + * + * Creates new inflator instance with specified params. Throws exception + * on bad params. Supported options: + * + * - `windowBits` + * - `dictionary` + * + * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced) + * for more information on these. + * + * Additional options, for internal needs: + * + * - `chunkSize` - size of generated data chunks (16K by default) + * - `raw` (Boolean) - do raw inflate + * - `to` (String) - if equal to 'string', then result will be converted + * from utf8 to utf16 (javascript) string. When string output requested, + * chunk length can differ from `chunkSize`, depending on content. + * + * By default, when no options set, autodetect deflate/gzip data format via + * wrapper header. + * + * ##### Example: + * + * ```javascript + * const pako = require('pako') + * const chunk1 = new Uint8Array([1,2,3,4,5,6,7,8,9]) + * const chunk2 = new Uint8Array([10,11,12,13,14,15,16,17,18,19]); + * + * const inflate = new pako.Inflate({ level: 3}); + * + * inflate.push(chunk1, false); + * inflate.push(chunk2, true); // true -> last chunk + * + * if (inflate.err) { throw new Error(inflate.err); } + * + * console.log(inflate.result); + * ``` + **/ +function Inflate(options) { + this.options = utils.assign({ + chunkSize: 1024 * 64, + windowBits: 15, + to: '' + }, options || {}); + + const opt = this.options; + + // Force window size for `raw` data, if not set directly, + // because we have no header for autodetect. + if (opt.raw && (opt.windowBits >= 0) && (opt.windowBits < 16)) { + opt.windowBits = -opt.windowBits; + if (opt.windowBits === 0) { opt.windowBits = -15; } + } + + // If `windowBits` not defined (and mode not raw) - set autodetect flag for gzip/deflate + if ((opt.windowBits >= 0) && (opt.windowBits < 16) && + !(options && options.windowBits)) { + opt.windowBits += 32; + } + + // Gzip header has no info about windows size, we can do autodetect only + // for deflate. So, if window size not set, force it to max when gzip possible + if ((opt.windowBits > 15) && (opt.windowBits < 48)) { + // bit 3 (16) -> gzipped data + // bit 4 (32) -> autodetect gzip/deflate + if ((opt.windowBits & 15) === 0) { + opt.windowBits |= 15; + } + } + + this.err = 0; // error code, if happens (0 = Z_OK) + this.msg = ''; // error message + this.ended = false; // used to avoid multiple onEnd() calls + this.chunks = []; // chunks of compressed data + + this.strm = new ZStream(); + this.strm.avail_out = 0; + + let status = zlib_inflate.inflateInit2( + this.strm, + opt.windowBits + ); + + if (status !== Z_OK) { + throw new Error(msg[status]); + } + + this.header = new GZheader(); + + zlib_inflate.inflateGetHeader(this.strm, this.header); + + // Setup dictionary + if (opt.dictionary) { + // Convert data if needed + if (typeof opt.dictionary === 'string') { + opt.dictionary = strings.string2buf(opt.dictionary); + } else if (toString.call(opt.dictionary) === '[object ArrayBuffer]') { + opt.dictionary = new Uint8Array(opt.dictionary); + } + if (opt.raw) { //In raw mode we need to set the dictionary early + status = zlib_inflate.inflateSetDictionary(this.strm, opt.dictionary); + if (status !== Z_OK) { + throw new Error(msg[status]); + } + } + } +} + +/** + * Inflate#push(data[, flush_mode]) -> Boolean + * - data (Uint8Array|ArrayBuffer): input data + * - flush_mode (Number|Boolean): 0..6 for corresponding Z_NO_FLUSH..Z_TREE + * flush modes. See constants. Skipped or `false` means Z_NO_FLUSH, + * `true` means Z_FINISH. + * + * Sends input data to inflate pipe, generating [[Inflate#onData]] calls with + * new output chunks. Returns `true` on success. If end of stream detected, + * [[Inflate#onEnd]] will be called. + * + * `flush_mode` is not needed for normal operation, because end of stream + * detected automatically. You may try to use it for advanced things, but + * this functionality was not tested. + * + * On fail call [[Inflate#onEnd]] with error code and return false. + * + * ##### Example + * + * ```javascript + * push(chunk, false); // push one of data chunks + * ... + * push(chunk, true); // push last chunk + * ``` + **/ +Inflate.prototype.push = function (data, flush_mode) { + const strm = this.strm; + const chunkSize = this.options.chunkSize; + const dictionary = this.options.dictionary; + let status, _flush_mode, last_avail_out; + + if (this.ended) return false; + + if (flush_mode === ~~flush_mode) _flush_mode = flush_mode; + else _flush_mode = flush_mode === true ? Z_FINISH : Z_NO_FLUSH; + + // Convert data if needed + if (toString.call(data) === '[object ArrayBuffer]') { + strm.input = new Uint8Array(data); + } else { + strm.input = data; + } + + strm.next_in = 0; + strm.avail_in = strm.input.length; + + for (;;) { + if (strm.avail_out === 0) { + strm.output = new Uint8Array(chunkSize); + strm.next_out = 0; + strm.avail_out = chunkSize; + } + + status = zlib_inflate.inflate(strm, _flush_mode); + + if (status === Z_NEED_DICT && dictionary) { + status = zlib_inflate.inflateSetDictionary(strm, dictionary); + + if (status === Z_OK) { + status = zlib_inflate.inflate(strm, _flush_mode); + } else if (status === Z_DATA_ERROR) { + // Replace code with more verbose + status = Z_NEED_DICT; + } + } + + // Skip snyc markers if more data follows and not raw mode + while (strm.avail_in > 0 && + status === Z_STREAM_END && + strm.state.wrap > 0 && + data[strm.next_in] !== 0) + { + zlib_inflate.inflateReset(strm); + status = zlib_inflate.inflate(strm, _flush_mode); + } + + switch (status) { + case Z_STREAM_ERROR: + case Z_DATA_ERROR: + case Z_NEED_DICT: + case Z_MEM_ERROR: + this.onEnd(status); + this.ended = true; + return false; + } + + // Remember real `avail_out` value, because we may patch out buffer content + // to align utf8 strings boundaries. + last_avail_out = strm.avail_out; + + if (strm.next_out) { + if (strm.avail_out === 0 || status === Z_STREAM_END) { + + if (this.options.to === 'string') { + + let next_out_utf8 = strings.utf8border(strm.output, strm.next_out); + + let tail = strm.next_out - next_out_utf8; + let utf8str = strings.buf2string(strm.output, next_out_utf8); + + // move tail & realign counters + strm.next_out = tail; + strm.avail_out = chunkSize - tail; + if (tail) strm.output.set(strm.output.subarray(next_out_utf8, next_out_utf8 + tail), 0); + + this.onData(utf8str); + + } else { + this.onData(strm.output.length === strm.next_out ? strm.output : strm.output.subarray(0, strm.next_out)); + } + } + } + + // Must repeat iteration if out buffer is full + if (status === Z_OK && last_avail_out === 0) continue; + + // Finalize if end of stream reached. + if (status === Z_STREAM_END) { + status = zlib_inflate.inflateEnd(this.strm); + this.onEnd(status); + this.ended = true; + return true; + } + + if (strm.avail_in === 0) break; + } + + return true; +}; + + +/** + * Inflate#onData(chunk) -> Void + * - chunk (Uint8Array|String): output data. When string output requested, + * each chunk will be string. + * + * By default, stores data blocks in `chunks[]` property and glue + * those in `onEnd`. Override this handler, if you need another behaviour. + **/ +Inflate.prototype.onData = function (chunk) { + this.chunks.push(chunk); +}; + + +/** + * Inflate#onEnd(status) -> Void + * - status (Number): inflate status. 0 (Z_OK) on success, + * other if not. + * + * Called either after you tell inflate that the input stream is + * complete (Z_FINISH). By default - join collected chunks, + * free memory and fill `results` / `err` properties. + **/ +Inflate.prototype.onEnd = function (status) { + // On success - join + if (status === Z_OK) { + if (this.options.to === 'string') { + this.result = this.chunks.join(''); + } else { + this.result = utils.flattenChunks(this.chunks); + } + } + this.chunks = []; + this.err = status; + this.msg = this.strm.msg; +}; + + +/** + * inflate(data[, options]) -> Uint8Array|String + * - data (Uint8Array|ArrayBuffer): input data to decompress. + * - options (Object): zlib inflate options. + * + * Decompress `data` with inflate/ungzip and `options`. Autodetect + * format via wrapper header by default. That's why we don't provide + * separate `ungzip` method. + * + * Supported options are: + * + * - windowBits + * + * [http://zlib.net/manual.html#Advanced](http://zlib.net/manual.html#Advanced) + * for more information. + * + * Sugar (options): + * + * - `raw` (Boolean) - say that we work with raw stream, if you don't wish to specify + * negative windowBits implicitly. + * - `to` (String) - if equal to 'string', then result will be converted + * from utf8 to utf16 (javascript) string. When string output requested, + * chunk length can differ from `chunkSize`, depending on content. + * + * + * ##### Example: + * + * ```javascript + * const pako = require('pako'); + * const input = pako.deflate(new Uint8Array([1,2,3,4,5,6,7,8,9])); + * let output; + * + * try { + * output = pako.inflate(input); + * } catch (err) { + * console.log(err); + * } + * ``` + **/ +function inflate(input, options) { + const inflator = new Inflate(options); + + inflator.push(input); + + // That will never happens, if you don't cheat with options :) + if (inflator.err) throw inflator.msg || msg[inflator.err]; + + return inflator.result; +} + + +/** + * inflateRaw(data[, options]) -> Uint8Array|String + * - data (Uint8Array|ArrayBuffer): input data to decompress. + * - options (Object): zlib inflate options. + * + * The same as [[inflate]], but creates raw data, without wrapper + * (header and adler32 crc). + **/ +function inflateRaw(input, options) { + options = options || {}; + options.raw = true; + return inflate(input, options); +} + + +/** + * ungzip(data[, options]) -> Uint8Array|String + * - data (Uint8Array|ArrayBuffer): input data to decompress. + * - options (Object): zlib inflate options. + * + * Just shortcut to [[inflate]], because it autodetects format + * by header.content. Done for convenience. + **/ + + +module.exports.Inflate = Inflate; +module.exports.inflate = inflate; +module.exports.inflateRaw = inflateRaw; +module.exports.ungzip = inflate; +module.exports.constants = require('./zlib/constants'); diff --git a/blue_modules/pako/lib/utils/common.js b/blue_modules/pako/lib/utils/common.js new file mode 100644 index 00000000000..9a6447a4d6e --- /dev/null +++ b/blue_modules/pako/lib/utils/common.js @@ -0,0 +1,48 @@ +'use strict'; + + +const _has = (obj, key) => { + return Object.prototype.hasOwnProperty.call(obj, key); +}; + +module.exports.assign = function (obj /*from1, from2, from3, ...*/) { + const sources = Array.prototype.slice.call(arguments, 1); + while (sources.length) { + const source = sources.shift(); + if (!source) { continue; } + + if (typeof source !== 'object') { + throw new TypeError(source + 'must be non-object'); + } + + for (const p in source) { + if (_has(source, p)) { + obj[p] = source[p]; + } + } + } + + return obj; +}; + + +// Join array of chunks to single array. +module.exports.flattenChunks = (chunks) => { + // calculate data length + let len = 0; + + for (let i = 0, l = chunks.length; i < l; i++) { + len += chunks[i].length; + } + + // join chunks + const result = new Uint8Array(len); + + for (let i = 0, pos = 0, l = chunks.length; i < l; i++) { + let chunk = chunks[i]; + result.set(chunk, pos); + pos += chunk.length; + } + + return result; +}; diff --git a/blue_modules/pako/lib/utils/strings.js b/blue_modules/pako/lib/utils/strings.js new file mode 100644 index 00000000000..c8f097cdbaa --- /dev/null +++ b/blue_modules/pako/lib/utils/strings.js @@ -0,0 +1,174 @@ +// String encode/decode helpers +'use strict'; + + +// Quick check if we can use fast array to bin string conversion +// +// - apply(Array) can fail on Android 2.2 +// - apply(Uint8Array) can fail on iOS 5.1 Safari +// +let STR_APPLY_UIA_OK = true; + +try { String.fromCharCode.apply(null, new Uint8Array(1)); } catch (__) { STR_APPLY_UIA_OK = false; } + + +// Table with utf8 lengths (calculated by first byte of sequence) +// Note, that 5 & 6-byte values and some 4-byte values can not be represented in JS, +// because max possible codepoint is 0x10ffff +const _utf8len = new Uint8Array(256); +for (let q = 0; q < 256; q++) { + _utf8len[q] = (q >= 252 ? 6 : q >= 248 ? 5 : q >= 240 ? 4 : q >= 224 ? 3 : q >= 192 ? 2 : 1); +} +_utf8len[254] = _utf8len[254] = 1; // Invalid sequence start + + +// convert string to array (typed, when possible) +module.exports.string2buf = (str) => { + if (typeof TextEncoder === 'function' && TextEncoder.prototype.encode) { + return new TextEncoder().encode(str); + } + + let buf, c, c2, m_pos, i, str_len = str.length, buf_len = 0; + + // count binary size + for (m_pos = 0; m_pos < str_len; m_pos++) { + c = str.charCodeAt(m_pos); + if ((c & 0xfc00) === 0xd800 && (m_pos + 1 < str_len)) { + c2 = str.charCodeAt(m_pos + 1); + if ((c2 & 0xfc00) === 0xdc00) { + c = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + m_pos++; + } + } + buf_len += c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; + } + + // allocate buffer + buf = new Uint8Array(buf_len); + + // convert + for (i = 0, m_pos = 0; i < buf_len; m_pos++) { + c = str.charCodeAt(m_pos); + if ((c & 0xfc00) === 0xd800 && (m_pos + 1 < str_len)) { + c2 = str.charCodeAt(m_pos + 1); + if ((c2 & 0xfc00) === 0xdc00) { + c = 0x10000 + ((c - 0xd800) << 10) + (c2 - 0xdc00); + m_pos++; + } + } + if (c < 0x80) { + /* one byte */ + buf[i++] = c; + } else if (c < 0x800) { + /* two bytes */ + buf[i++] = 0xC0 | (c >>> 6); + buf[i++] = 0x80 | (c & 0x3f); + } else if (c < 0x10000) { + /* three bytes */ + buf[i++] = 0xE0 | (c >>> 12); + buf[i++] = 0x80 | (c >>> 6 & 0x3f); + buf[i++] = 0x80 | (c & 0x3f); + } else { + /* four bytes */ + buf[i++] = 0xf0 | (c >>> 18); + buf[i++] = 0x80 | (c >>> 12 & 0x3f); + buf[i++] = 0x80 | (c >>> 6 & 0x3f); + buf[i++] = 0x80 | (c & 0x3f); + } + } + + return buf; +}; + +// Helper +const buf2binstring = (buf, len) => { + // On Chrome, the arguments in a function call that are allowed is `65534`. + // If the length of the buffer is smaller than that, we can use this optimization, + // otherwise we will take a slower path. + if (len < 65534) { + if (buf.subarray && STR_APPLY_UIA_OK) { + return String.fromCharCode.apply(null, buf.length === len ? buf : buf.subarray(0, len)); + } + } + + let result = ''; + for (let i = 0; i < len; i++) { + result += String.fromCharCode(buf[i]); + } + return result; +}; + + +// convert array to string +module.exports.buf2string = (buf, max) => { + const len = max || buf.length; + + if (typeof TextDecoder === 'function' && TextDecoder.prototype.decode) { + return new TextDecoder().decode(buf.subarray(0, max)); + } + + let i, out; + + // Reserve max possible length (2 words per char) + // NB: by unknown reasons, Array is significantly faster for + // String.fromCharCode.apply than Uint16Array. + const utf16buf = new Array(len * 2); + + for (out = 0, i = 0; i < len;) { + let c = buf[i++]; + // quick process ascii + if (c < 0x80) { utf16buf[out++] = c; continue; } + + let c_len = _utf8len[c]; + // skip 5 & 6 byte codes + if (c_len > 4) { utf16buf[out++] = 0xfffd; i += c_len - 1; continue; } + + // apply mask on first byte + c &= c_len === 2 ? 0x1f : c_len === 3 ? 0x0f : 0x07; + // join the rest + while (c_len > 1 && i < len) { + c = (c << 6) | (buf[i++] & 0x3f); + c_len--; + } + + // terminated by end of string? + if (c_len > 1) { utf16buf[out++] = 0xfffd; continue; } + + if (c < 0x10000) { + utf16buf[out++] = c; + } else { + c -= 0x10000; + utf16buf[out++] = 0xd800 | ((c >> 10) & 0x3ff); + utf16buf[out++] = 0xdc00 | (c & 0x3ff); + } + } + + return buf2binstring(utf16buf, out); +}; + + +// Calculate max possible position in utf8 buffer, +// that will not break sequence. If that's not possible +// - (very small limits) return max size as is. +// +// buf[] - utf8 bytes array +// max - length limit (mandatory); +module.exports.utf8border = (buf, max) => { + + max = max || buf.length; + if (max > buf.length) { max = buf.length; } + + // go back from last position, until start of sequence found + let pos = max - 1; + while (pos >= 0 && (buf[pos] & 0xC0) === 0x80) { pos--; } + + // Very small and broken sequence, + // return max, because we should return something anyway. + if (pos < 0) { return max; } + + // If we came to start of buffer - that means buffer is too small, + // return max too. + if (pos === 0) { return max; } + + return (pos + _utf8len[buf[pos]] > max) ? pos : max; +}; diff --git a/blue_modules/pako/lib/zlib/README b/blue_modules/pako/lib/zlib/README new file mode 100644 index 00000000000..88a8752470d --- /dev/null +++ b/blue_modules/pako/lib/zlib/README @@ -0,0 +1,59 @@ +Content of this folder follows zlib C sources as close as possible. +That's intended to simplify maintainability and guarantee equal API +and result. + +Key differences: + +- Everything is in JavaScript. +- No platform-dependent blocks. +- Some things like crc32 rewritten to keep size small and make JIT + work better. +- Some code is different due missed features in JS (macros, pointers, + structures, header files) +- Specific API methods are not implemented (see notes in root readme) + +This port is based on zlib 1.2.8. + +This port is under zlib license (see below) with contribution and addition of javascript +port under expat license (see LICENSE at root of project) + +Copyright: +(C) 1995-2013 Jean-loup Gailly and Mark Adler +(C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin + + +From zlib's README +============================================================================= + +Acknowledgments: + + The deflate format used by zlib was defined by Phil Katz. The deflate and + zlib specifications were written by L. Peter Deutsch. Thanks to all the + people who reported problems and suggested various improvements in zlib; they + are too numerous to cite here. + +Copyright notice: + + (C) 1995-2013 Jean-loup Gailly and Mark Adler + +Copyright (c) <''year''> <''copyright holders''> + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + + + Jean-loup Gailly Mark Adler + jloup@gzip.org madler@alumni.caltech.edu diff --git a/blue_modules/pako/lib/zlib/adler32.js b/blue_modules/pako/lib/zlib/adler32.js new file mode 100644 index 00000000000..d65072afdbf --- /dev/null +++ b/blue_modules/pako/lib/zlib/adler32.js @@ -0,0 +1,51 @@ +'use strict'; + +// Note: adler32 takes 12% for level 0 and 2% for level 6. +// It isn't worth it to make additional optimizations as in original. +// Small size is preferable. + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +const adler32 = (adler, buf, len, pos) => { + let s1 = (adler & 0xffff) |0, + s2 = ((adler >>> 16) & 0xffff) |0, + n = 0; + + while (len !== 0) { + // Set limit ~ twice less than 5552, to keep + // s2 in 31-bits, because we force signed ints. + // in other case %= will fail. + n = len > 2000 ? 2000 : len; + len -= n; + + do { + s1 = (s1 + buf[pos++]) |0; + s2 = (s2 + s1) |0; + } while (--n); + + s1 %= 65521; + s2 %= 65521; + } + + return (s1 | (s2 << 16)) |0; +}; + + +module.exports = adler32; diff --git a/blue_modules/pako/lib/zlib/constants.js b/blue_modules/pako/lib/zlib/constants.js new file mode 100644 index 00000000000..b85cc0191d7 --- /dev/null +++ b/blue_modules/pako/lib/zlib/constants.js @@ -0,0 +1,68 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +module.exports = { + + /* Allowed flush values; see deflate() and inflate() below for details */ + Z_NO_FLUSH: 0, + Z_PARTIAL_FLUSH: 1, + Z_SYNC_FLUSH: 2, + Z_FULL_FLUSH: 3, + Z_FINISH: 4, + Z_BLOCK: 5, + Z_TREES: 6, + + /* Return codes for the compression/decompression functions. Negative values + * are errors, positive values are used for special but normal events. + */ + Z_OK: 0, + Z_STREAM_END: 1, + Z_NEED_DICT: 2, + Z_ERRNO: -1, + Z_STREAM_ERROR: -2, + Z_DATA_ERROR: -3, + Z_MEM_ERROR: -4, + Z_BUF_ERROR: -5, + //Z_VERSION_ERROR: -6, + + /* compression levels */ + Z_NO_COMPRESSION: 0, + Z_BEST_SPEED: 1, + Z_BEST_COMPRESSION: 9, + Z_DEFAULT_COMPRESSION: -1, + + + Z_FILTERED: 1, + Z_HUFFMAN_ONLY: 2, + Z_RLE: 3, + Z_FIXED: 4, + Z_DEFAULT_STRATEGY: 0, + + /* Possible values of the data_type field (though see inflate()) */ + Z_BINARY: 0, + Z_TEXT: 1, + //Z_ASCII: 1, // = Z_TEXT (deprecated) + Z_UNKNOWN: 2, + + /* The deflate compression method */ + Z_DEFLATED: 8 + //Z_NULL: null // Use -1 or null inline, depending on var type +}; diff --git a/blue_modules/pako/lib/zlib/crc32.js b/blue_modules/pako/lib/zlib/crc32.js new file mode 100644 index 00000000000..60cbd51ec44 --- /dev/null +++ b/blue_modules/pako/lib/zlib/crc32.js @@ -0,0 +1,59 @@ +'use strict'; + +// Note: we can't get significant speed boost here. +// So write code to minimize size - no pregenerated tables +// and array tools dependencies. + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +// Use ordinary array, since untyped makes no boost here +const makeTable = () => { + let c, table = []; + + for (var n = 0; n < 256; n++) { + c = n; + for (var k = 0; k < 8; k++) { + c = ((c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)); + } + table[n] = c; + } + + return table; +}; + +// Create table on load. Just 255 signed longs. Not a problem. +const crcTable = new Uint32Array(makeTable()); + + +const crc32 = (crc, buf, len, pos) => { + const t = crcTable; + const end = pos + len; + + crc ^= -1; + + for (let i = pos; i < end; i++) { + crc = (crc >>> 8) ^ t[(crc ^ buf[i]) & 0xFF]; + } + + return (crc ^ (-1)); // >>> 0; +}; + + +module.exports = crc32; diff --git a/blue_modules/pako/lib/zlib/deflate.js b/blue_modules/pako/lib/zlib/deflate.js new file mode 100644 index 00000000000..00e056eb7d6 --- /dev/null +++ b/blue_modules/pako/lib/zlib/deflate.js @@ -0,0 +1,2048 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +const { _tr_init, _tr_stored_block, _tr_flush_block, _tr_tally, _tr_align } = require('./trees'); +const adler32 = require('./adler32'); +const crc32 = require('./crc32'); +const msg = require('./messages'); + +/* Public constants ==========================================================*/ +/* ===========================================================================*/ + +const { + Z_NO_FLUSH, Z_PARTIAL_FLUSH, Z_FULL_FLUSH, Z_FINISH, Z_BLOCK, + Z_OK, Z_STREAM_END, Z_STREAM_ERROR, Z_DATA_ERROR, Z_BUF_ERROR, + Z_DEFAULT_COMPRESSION, + Z_FILTERED, Z_HUFFMAN_ONLY, Z_RLE, Z_FIXED, Z_DEFAULT_STRATEGY, + Z_UNKNOWN, + Z_DEFLATED +} = require('./constants'); + +/*============================================================================*/ + + +const MAX_MEM_LEVEL = 9; +/* Maximum value for memLevel in deflateInit2 */ +const MAX_WBITS = 15; +/* 32K LZ77 window */ +const DEF_MEM_LEVEL = 8; + + +const LENGTH_CODES = 29; +/* number of length codes, not counting the special END_BLOCK code */ +const LITERALS = 256; +/* number of literal bytes 0..255 */ +const L_CODES = LITERALS + 1 + LENGTH_CODES; +/* number of Literal or Length codes, including the END_BLOCK code */ +const D_CODES = 30; +/* number of distance codes */ +const BL_CODES = 19; +/* number of codes used to transfer the bit lengths */ +const HEAP_SIZE = 2 * L_CODES + 1; +/* maximum heap size */ +const MAX_BITS = 15; +/* All codes must not exceed MAX_BITS bits */ + +const MIN_MATCH = 3; +const MAX_MATCH = 258; +const MIN_LOOKAHEAD = (MAX_MATCH + MIN_MATCH + 1); + +const PRESET_DICT = 0x20; + +const INIT_STATE = 42; /* zlib header -> BUSY_STATE */ +//#ifdef GZIP +const GZIP_STATE = 57; /* gzip header -> BUSY_STATE | EXTRA_STATE */ +//#endif +const EXTRA_STATE = 69; /* gzip extra block -> NAME_STATE */ +const NAME_STATE = 73; /* gzip file name -> COMMENT_STATE */ +const COMMENT_STATE = 91; /* gzip comment -> HCRC_STATE */ +const HCRC_STATE = 103; /* gzip header CRC -> BUSY_STATE */ +const BUSY_STATE = 113; /* deflate -> FINISH_STATE */ +const FINISH_STATE = 666; /* stream complete */ + +const BS_NEED_MORE = 1; /* block not completed, need more input or more output */ +const BS_BLOCK_DONE = 2; /* block flush performed */ +const BS_FINISH_STARTED = 3; /* finish started, need only more output at next deflate */ +const BS_FINISH_DONE = 4; /* finish done, accept no more input or output */ + +const OS_CODE = 0x03; // Unix :) . Don't detect, use this default. + +const err = (strm, errorCode) => { + strm.msg = msg[errorCode]; + return errorCode; +}; + +const rank = (f) => { + return ((f) * 2) - ((f) > 4 ? 9 : 0); +}; + +const zero = (buf) => { + let len = buf.length; while (--len >= 0) { buf[len] = 0; } +}; + +/* =========================================================================== + * Slide the hash table when sliding the window down (could be avoided with 32 + * bit values at the expense of memory usage). We slide even when level == 0 to + * keep the hash table consistent if we switch back to level > 0 later. + */ +const slide_hash = (s) => { + let n, m; + let p; + let wsize = s.w_size; + + n = s.hash_size; + p = n; + do { + m = s.head[--p]; + s.head[p] = (m >= wsize ? m - wsize : 0); + } while (--n); + n = wsize; +//#ifndef FASTEST + p = n; + do { + m = s.prev[--p]; + s.prev[p] = (m >= wsize ? m - wsize : 0); + /* If n is not on any hash chain, prev[n] is garbage but + * its value will never be used. + */ + } while (--n); +//#endif +}; + +/* eslint-disable new-cap */ +let HASH_ZLIB = (s, prev, data) => ((prev << s.hash_shift) ^ data) & s.hash_mask; +// This hash causes less collisions, https://github.com/nodeca/pako/issues/135 +// But breaks binary compatibility +//let HASH_FAST = (s, prev, data) => ((prev << 8) + (prev >> 8) + (data << 4)) & s.hash_mask; +let HASH = HASH_ZLIB; + + +/* ========================================================================= + * Flush as much pending output as possible. All deflate() output, except for + * some deflate_stored() output, goes through this function so some + * applications may wish to modify it to avoid allocating a large + * strm->next_out buffer and copying into it. (See also read_buf()). + */ +const flush_pending = (strm) => { + const s = strm.state; + + //_tr_flush_bits(s); + let len = s.pending; + if (len > strm.avail_out) { + len = strm.avail_out; + } + if (len === 0) { return; } + + strm.output.set(s.pending_buf.subarray(s.pending_out, s.pending_out + len), strm.next_out); + strm.next_out += len; + s.pending_out += len; + strm.total_out += len; + strm.avail_out -= len; + s.pending -= len; + if (s.pending === 0) { + s.pending_out = 0; + } +}; + + +const flush_block_only = (s, last) => { + _tr_flush_block(s, (s.block_start >= 0 ? s.block_start : -1), s.strstart - s.block_start, last); + s.block_start = s.strstart; + flush_pending(s.strm); +}; + + +const put_byte = (s, b) => { + s.pending_buf[s.pending++] = b; +}; + + +/* ========================================================================= + * Put a short in the pending buffer. The 16-bit value is put in MSB order. + * IN assertion: the stream state is correct and there is enough room in + * pending_buf. + */ +const putShortMSB = (s, b) => { + + // put_byte(s, (Byte)(b >> 8)); +// put_byte(s, (Byte)(b & 0xff)); + s.pending_buf[s.pending++] = (b >>> 8) & 0xff; + s.pending_buf[s.pending++] = b & 0xff; +}; + + +/* =========================================================================== + * Read a new buffer from the current input stream, update the adler32 + * and total number of bytes read. All deflate() input goes through + * this function so some applications may wish to modify it to avoid + * allocating a large strm->input buffer and copying from it. + * (See also flush_pending()). + */ +const read_buf = (strm, buf, start, size) => { + + let len = strm.avail_in; + + if (len > size) { len = size; } + if (len === 0) { return 0; } + + strm.avail_in -= len; + + // zmemcpy(buf, strm->next_in, len); + buf.set(strm.input.subarray(strm.next_in, strm.next_in + len), start); + if (strm.state.wrap === 1) { + strm.adler = adler32(strm.adler, buf, len, start); + } + + else if (strm.state.wrap === 2) { + strm.adler = crc32(strm.adler, buf, len, start); + } + + strm.next_in += len; + strm.total_in += len; + + return len; +}; + + +/* =========================================================================== + * Set match_start to the longest match starting at the given string and + * return its length. Matches shorter or equal to prev_length are discarded, + * in which case the result is equal to prev_length and match_start is + * garbage. + * IN assertions: cur_match is the head of the hash chain for the current + * string (strstart) and its distance is <= MAX_DIST, and prev_length >= 1 + * OUT assertion: the match length is not greater than s->lookahead. + */ +const longest_match = (s, cur_match) => { + + let chain_length = s.max_chain_length; /* max hash chain length */ + let scan = s.strstart; /* current string */ + let match; /* matched string */ + let len; /* length of current match */ + let best_len = s.prev_length; /* best match length so far */ + let nice_match = s.nice_match; /* stop if match long enough */ + const limit = (s.strstart > (s.w_size - MIN_LOOKAHEAD)) ? + s.strstart - (s.w_size - MIN_LOOKAHEAD) : 0/*NIL*/; + + const _win = s.window; // shortcut + + const wmask = s.w_mask; + const prev = s.prev; + + /* Stop when cur_match becomes <= limit. To simplify the code, + * we prevent matches with the string of window index 0. + */ + + const strend = s.strstart + MAX_MATCH; + let scan_end1 = _win[scan + best_len - 1]; + let scan_end = _win[scan + best_len]; + + /* The code is optimized for HASH_BITS >= 8 and MAX_MATCH-2 multiple of 16. + * It is easy to get rid of this optimization if necessary. + */ + // Assert(s->hash_bits >= 8 && MAX_MATCH == 258, "Code too clever"); + + /* Do not waste too much time if we already have a good match: */ + if (s.prev_length >= s.good_match) { + chain_length >>= 2; + } + /* Do not look for matches beyond the end of the input. This is necessary + * to make deflate deterministic. + */ + if (nice_match > s.lookahead) { nice_match = s.lookahead; } + + // Assert((ulg)s->strstart <= s->window_size-MIN_LOOKAHEAD, "need lookahead"); + + do { + // Assert(cur_match < s->strstart, "no future"); + match = cur_match; + + /* Skip to next match if the match length cannot increase + * or if the match length is less than 2. Note that the checks below + * for insufficient lookahead only occur occasionally for performance + * reasons. Therefore uninitialized memory will be accessed, and + * conditional jumps will be made that depend on those values. + * However the length of the match is limited to the lookahead, so + * the output of deflate is not affected by the uninitialized values. + */ + + if (_win[match + best_len] !== scan_end || + _win[match + best_len - 1] !== scan_end1 || + _win[match] !== _win[scan] || + _win[++match] !== _win[scan + 1]) { + continue; + } + + /* The check at best_len-1 can be removed because it will be made + * again later. (This heuristic is not always a win.) + * It is not necessary to compare scan[2] and match[2] since they + * are always equal when the other bytes match, given that + * the hash keys are equal and that HASH_BITS >= 8. + */ + scan += 2; + match++; + // Assert(*scan == *match, "match[2]?"); + + /* We check for insufficient lookahead only every 8th comparison; + * the 256th check will be made at strstart+258. + */ + do { + /*jshint noempty:false*/ + } while (_win[++scan] === _win[++match] && _win[++scan] === _win[++match] && + _win[++scan] === _win[++match] && _win[++scan] === _win[++match] && + _win[++scan] === _win[++match] && _win[++scan] === _win[++match] && + _win[++scan] === _win[++match] && _win[++scan] === _win[++match] && + scan < strend); + + // Assert(scan <= s->window+(unsigned)(s->window_size-1), "wild scan"); + + len = MAX_MATCH - (strend - scan); + scan = strend - MAX_MATCH; + + if (len > best_len) { + s.match_start = cur_match; + best_len = len; + if (len >= nice_match) { + break; + } + scan_end1 = _win[scan + best_len - 1]; + scan_end = _win[scan + best_len]; + } + } while ((cur_match = prev[cur_match & wmask]) > limit && --chain_length !== 0); + + if (best_len <= s.lookahead) { + return best_len; + } + return s.lookahead; +}; + + +/* =========================================================================== + * Fill the window when the lookahead becomes insufficient. + * Updates strstart and lookahead. + * + * IN assertion: lookahead < MIN_LOOKAHEAD + * OUT assertions: strstart <= window_size-MIN_LOOKAHEAD + * At least one byte has been read, or avail_in == 0; reads are + * performed for at least two bytes (required for the zip translate_eol + * option -- not supported here). + */ +const fill_window = (s) => { + + const _w_size = s.w_size; + let n, more, str; + + //Assert(s->lookahead < MIN_LOOKAHEAD, "already enough lookahead"); + + do { + more = s.window_size - s.lookahead - s.strstart; + + // JS ints have 32 bit, block below not needed + /* Deal with !@#$% 64K limit: */ + //if (sizeof(int) <= 2) { + // if (more == 0 && s->strstart == 0 && s->lookahead == 0) { + // more = wsize; + // + // } else if (more == (unsigned)(-1)) { + // /* Very unlikely, but possible on 16 bit machine if + // * strstart == 0 && lookahead == 1 (input done a byte at time) + // */ + // more--; + // } + //} + + + /* If the window is almost full and there is insufficient lookahead, + * move the upper half to the lower one to make room in the upper half. + */ + if (s.strstart >= _w_size + (_w_size - MIN_LOOKAHEAD)) { + + s.window.set(s.window.subarray(_w_size, _w_size + _w_size - more), 0); + s.match_start -= _w_size; + s.strstart -= _w_size; + /* we now have strstart >= MAX_DIST */ + s.block_start -= _w_size; + if (s.insert > s.strstart) { + s.insert = s.strstart; + } + slide_hash(s); + more += _w_size; + } + if (s.strm.avail_in === 0) { + break; + } + + /* If there was no sliding: + * strstart <= WSIZE+MAX_DIST-1 && lookahead <= MIN_LOOKAHEAD - 1 && + * more == window_size - lookahead - strstart + * => more >= window_size - (MIN_LOOKAHEAD-1 + WSIZE + MAX_DIST-1) + * => more >= window_size - 2*WSIZE + 2 + * In the BIG_MEM or MMAP case (not yet supported), + * window_size == input_size + MIN_LOOKAHEAD && + * strstart + s->lookahead <= input_size => more >= MIN_LOOKAHEAD. + * Otherwise, window_size == 2*WSIZE so more >= 2. + * If there was sliding, more >= WSIZE. So in all cases, more >= 2. + */ + //Assert(more >= 2, "more < 2"); + n = read_buf(s.strm, s.window, s.strstart + s.lookahead, more); + s.lookahead += n; + + /* Initialize the hash value now that we have some input: */ + if (s.lookahead + s.insert >= MIN_MATCH) { + str = s.strstart - s.insert; + s.ins_h = s.window[str]; + + /* UPDATE_HASH(s, s->ins_h, s->window[str + 1]); */ + s.ins_h = HASH(s, s.ins_h, s.window[str + 1]); +//#if MIN_MATCH != 3 +// Call update_hash() MIN_MATCH-3 more times +//#endif + while (s.insert) { + /* UPDATE_HASH(s, s->ins_h, s->window[str + MIN_MATCH-1]); */ + s.ins_h = HASH(s, s.ins_h, s.window[str + MIN_MATCH - 1]); + + s.prev[str & s.w_mask] = s.head[s.ins_h]; + s.head[s.ins_h] = str; + str++; + s.insert--; + if (s.lookahead + s.insert < MIN_MATCH) { + break; + } + } + } + /* If the whole input has less than MIN_MATCH bytes, ins_h is garbage, + * but this is not important since only literal bytes will be emitted. + */ + + } while (s.lookahead < MIN_LOOKAHEAD && s.strm.avail_in !== 0); + + /* If the WIN_INIT bytes after the end of the current data have never been + * written, then zero those bytes in order to avoid memory check reports of + * the use of uninitialized (or uninitialised as Julian writes) bytes by + * the longest match routines. Update the high water mark for the next + * time through here. WIN_INIT is set to MAX_MATCH since the longest match + * routines allow scanning to strstart + MAX_MATCH, ignoring lookahead. + */ +// if (s.high_water < s.window_size) { +// const curr = s.strstart + s.lookahead; +// let init = 0; +// +// if (s.high_water < curr) { +// /* Previous high water mark below current data -- zero WIN_INIT +// * bytes or up to end of window, whichever is less. +// */ +// init = s.window_size - curr; +// if (init > WIN_INIT) +// init = WIN_INIT; +// zmemzero(s->window + curr, (unsigned)init); +// s->high_water = curr + init; +// } +// else if (s->high_water < (ulg)curr + WIN_INIT) { +// /* High water mark at or above current data, but below current data +// * plus WIN_INIT -- zero out to current data plus WIN_INIT, or up +// * to end of window, whichever is less. +// */ +// init = (ulg)curr + WIN_INIT - s->high_water; +// if (init > s->window_size - s->high_water) +// init = s->window_size - s->high_water; +// zmemzero(s->window + s->high_water, (unsigned)init); +// s->high_water += init; +// } +// } +// +// Assert((ulg)s->strstart <= s->window_size - MIN_LOOKAHEAD, +// "not enough room for search"); +}; + +/* =========================================================================== + * Copy without compression as much as possible from the input stream, return + * the current block state. + * + * In case deflateParams() is used to later switch to a non-zero compression + * level, s->matches (otherwise unused when storing) keeps track of the number + * of hash table slides to perform. If s->matches is 1, then one hash table + * slide will be done when switching. If s->matches is 2, the maximum value + * allowed here, then the hash table will be cleared, since two or more slides + * is the same as a clear. + * + * deflate_stored() is written to minimize the number of times an input byte is + * copied. It is most efficient with large input and output buffers, which + * maximizes the opportunites to have a single copy from next_in to next_out. + */ +const deflate_stored = (s, flush) => { + + /* Smallest worthy block size when not flushing or finishing. By default + * this is 32K. This can be as small as 507 bytes for memLevel == 1. For + * large input and output buffers, the stored block size will be larger. + */ + let min_block = s.pending_buf_size - 5 > s.w_size ? s.w_size : s.pending_buf_size - 5; + + /* Copy as many min_block or larger stored blocks directly to next_out as + * possible. If flushing, copy the remaining available input to next_out as + * stored blocks, if there is enough space. + */ + let len, left, have, last = 0; + let used = s.strm.avail_in; + do { + /* Set len to the maximum size block that we can copy directly with the + * available input data and output space. Set left to how much of that + * would be copied from what's left in the window. + */ + len = 65535/* MAX_STORED */; /* maximum deflate stored block length */ + have = (s.bi_valid + 42) >> 3; /* number of header bytes */ + if (s.strm.avail_out < have) { /* need room for header */ + break; + } + /* maximum stored block length that will fit in avail_out: */ + have = s.strm.avail_out - have; + left = s.strstart - s.block_start; /* bytes left in window */ + if (len > left + s.strm.avail_in) { + len = left + s.strm.avail_in; /* limit len to the input */ + } + if (len > have) { + len = have; /* limit len to the output */ + } + + /* If the stored block would be less than min_block in length, or if + * unable to copy all of the available input when flushing, then try + * copying to the window and the pending buffer instead. Also don't + * write an empty block when flushing -- deflate() does that. + */ + if (len < min_block && ((len === 0 && flush !== Z_FINISH) || + flush === Z_NO_FLUSH || + len !== left + s.strm.avail_in)) { + break; + } + + /* Make a dummy stored block in pending to get the header bytes, + * including any pending bits. This also updates the debugging counts. + */ + last = flush === Z_FINISH && len === left + s.strm.avail_in ? 1 : 0; + _tr_stored_block(s, 0, 0, last); + + /* Replace the lengths in the dummy stored block with len. */ + s.pending_buf[s.pending - 4] = len; + s.pending_buf[s.pending - 3] = len >> 8; + s.pending_buf[s.pending - 2] = ~len; + s.pending_buf[s.pending - 1] = ~len >> 8; + + /* Write the stored block header bytes. */ + flush_pending(s.strm); + +//#ifdef ZLIB_DEBUG +// /* Update debugging counts for the data about to be copied. */ +// s->compressed_len += len << 3; +// s->bits_sent += len << 3; +//#endif + + /* Copy uncompressed bytes from the window to next_out. */ + if (left) { + if (left > len) { + left = len; + } + //zmemcpy(s->strm->next_out, s->window + s->block_start, left); + s.strm.output.set(s.window.subarray(s.block_start, s.block_start + left), s.strm.next_out); + s.strm.next_out += left; + s.strm.avail_out -= left; + s.strm.total_out += left; + s.block_start += left; + len -= left; + } + + /* Copy uncompressed bytes directly from next_in to next_out, updating + * the check value. + */ + if (len) { + read_buf(s.strm, s.strm.output, s.strm.next_out, len); + s.strm.next_out += len; + s.strm.avail_out -= len; + s.strm.total_out += len; + } + } while (last === 0); + + /* Update the sliding window with the last s->w_size bytes of the copied + * data, or append all of the copied data to the existing window if less + * than s->w_size bytes were copied. Also update the number of bytes to + * insert in the hash tables, in the event that deflateParams() switches to + * a non-zero compression level. + */ + used -= s.strm.avail_in; /* number of input bytes directly copied */ + if (used) { + /* If any input was used, then no unused input remains in the window, + * therefore s->block_start == s->strstart. + */ + if (used >= s.w_size) { /* supplant the previous history */ + s.matches = 2; /* clear hash */ + //zmemcpy(s->window, s->strm->next_in - s->w_size, s->w_size); + s.window.set(s.strm.input.subarray(s.strm.next_in - s.w_size, s.strm.next_in), 0); + s.strstart = s.w_size; + s.insert = s.strstart; + } + else { + if (s.window_size - s.strstart <= used) { + /* Slide the window down. */ + s.strstart -= s.w_size; + //zmemcpy(s->window, s->window + s->w_size, s->strstart); + s.window.set(s.window.subarray(s.w_size, s.w_size + s.strstart), 0); + if (s.matches < 2) { + s.matches++; /* add a pending slide_hash() */ + } + if (s.insert > s.strstart) { + s.insert = s.strstart; + } + } + //zmemcpy(s->window + s->strstart, s->strm->next_in - used, used); + s.window.set(s.strm.input.subarray(s.strm.next_in - used, s.strm.next_in), s.strstart); + s.strstart += used; + s.insert += used > s.w_size - s.insert ? s.w_size - s.insert : used; + } + s.block_start = s.strstart; + } + if (s.high_water < s.strstart) { + s.high_water = s.strstart; + } + + /* If the last block was written to next_out, then done. */ + if (last) { + return BS_FINISH_DONE; + } + + /* If flushing and all input has been consumed, then done. */ + if (flush !== Z_NO_FLUSH && flush !== Z_FINISH && + s.strm.avail_in === 0 && s.strstart === s.block_start) { + return BS_BLOCK_DONE; + } + + /* Fill the window with any remaining input. */ + have = s.window_size - s.strstart; + if (s.strm.avail_in > have && s.block_start >= s.w_size) { + /* Slide the window down. */ + s.block_start -= s.w_size; + s.strstart -= s.w_size; + //zmemcpy(s->window, s->window + s->w_size, s->strstart); + s.window.set(s.window.subarray(s.w_size, s.w_size + s.strstart), 0); + if (s.matches < 2) { + s.matches++; /* add a pending slide_hash() */ + } + have += s.w_size; /* more space now */ + if (s.insert > s.strstart) { + s.insert = s.strstart; + } + } + if (have > s.strm.avail_in) { + have = s.strm.avail_in; + } + if (have) { + read_buf(s.strm, s.window, s.strstart, have); + s.strstart += have; + s.insert += have > s.w_size - s.insert ? s.w_size - s.insert : have; + } + if (s.high_water < s.strstart) { + s.high_water = s.strstart; + } + + /* There was not enough avail_out to write a complete worthy or flushed + * stored block to next_out. Write a stored block to pending instead, if we + * have enough input for a worthy block, or if flushing and there is enough + * room for the remaining input as a stored block in the pending buffer. + */ + have = (s.bi_valid + 42) >> 3; /* number of header bytes */ + /* maximum stored block length that will fit in pending: */ + have = s.pending_buf_size - have > 65535/* MAX_STORED */ ? 65535/* MAX_STORED */ : s.pending_buf_size - have; + min_block = have > s.w_size ? s.w_size : have; + left = s.strstart - s.block_start; + if (left >= min_block || + ((left || flush === Z_FINISH) && flush !== Z_NO_FLUSH && + s.strm.avail_in === 0 && left <= have)) { + len = left > have ? have : left; + last = flush === Z_FINISH && s.strm.avail_in === 0 && + len === left ? 1 : 0; + _tr_stored_block(s, s.block_start, len, last); + s.block_start += len; + flush_pending(s.strm); + } + + /* We've done all we can with the available input and output. */ + return last ? BS_FINISH_STARTED : BS_NEED_MORE; +}; + + +/* =========================================================================== + * Compress as much as possible from the input stream, return the current + * block state. + * This function does not perform lazy evaluation of matches and inserts + * new strings in the dictionary only for unmatched strings or for short + * matches. It is used only for the fast compression options. + */ +const deflate_fast = (s, flush) => { + + let hash_head; /* head of the hash chain */ + let bflush; /* set if current block must be flushed */ + + for (;;) { + /* Make sure that we always have enough lookahead, except + * at the end of the input file. We need MAX_MATCH bytes + * for the next match, plus MIN_MATCH bytes to insert the + * string following the next match. + */ + if (s.lookahead < MIN_LOOKAHEAD) { + fill_window(s); + if (s.lookahead < MIN_LOOKAHEAD && flush === Z_NO_FLUSH) { + return BS_NEED_MORE; + } + if (s.lookahead === 0) { + break; /* flush the current block */ + } + } + + /* Insert the string window[strstart .. strstart+2] in the + * dictionary, and set hash_head to the head of the hash chain: + */ + hash_head = 0/*NIL*/; + if (s.lookahead >= MIN_MATCH) { + /*** INSERT_STRING(s, s.strstart, hash_head); ***/ + s.ins_h = HASH(s, s.ins_h, s.window[s.strstart + MIN_MATCH - 1]); + hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h]; + s.head[s.ins_h] = s.strstart; + /***/ + } + + /* Find the longest match, discarding those <= prev_length. + * At this point we have always match_length < MIN_MATCH + */ + if (hash_head !== 0/*NIL*/ && ((s.strstart - hash_head) <= (s.w_size - MIN_LOOKAHEAD))) { + /* To simplify the code, we prevent matches with the string + * of window index 0 (in particular we have to avoid a match + * of the string with itself at the start of the input file). + */ + s.match_length = longest_match(s, hash_head); + /* longest_match() sets match_start */ + } + if (s.match_length >= MIN_MATCH) { + // check_match(s, s.strstart, s.match_start, s.match_length); // for debug only + + /*** _tr_tally_dist(s, s.strstart - s.match_start, + s.match_length - MIN_MATCH, bflush); ***/ + bflush = _tr_tally(s, s.strstart - s.match_start, s.match_length - MIN_MATCH); + + s.lookahead -= s.match_length; + + /* Insert new strings in the hash table only if the match length + * is not too large. This saves time but degrades compression. + */ + if (s.match_length <= s.max_lazy_match/*max_insert_length*/ && s.lookahead >= MIN_MATCH) { + s.match_length--; /* string at strstart already in table */ + do { + s.strstart++; + /*** INSERT_STRING(s, s.strstart, hash_head); ***/ + s.ins_h = HASH(s, s.ins_h, s.window[s.strstart + MIN_MATCH - 1]); + hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h]; + s.head[s.ins_h] = s.strstart; + /***/ + /* strstart never exceeds WSIZE-MAX_MATCH, so there are + * always MIN_MATCH bytes ahead. + */ + } while (--s.match_length !== 0); + s.strstart++; + } else + { + s.strstart += s.match_length; + s.match_length = 0; + s.ins_h = s.window[s.strstart]; + /* UPDATE_HASH(s, s.ins_h, s.window[s.strstart+1]); */ + s.ins_h = HASH(s, s.ins_h, s.window[s.strstart + 1]); + +//#if MIN_MATCH != 3 +// Call UPDATE_HASH() MIN_MATCH-3 more times +//#endif + /* If lookahead < MIN_MATCH, ins_h is garbage, but it does not + * matter since it will be recomputed at next deflate call. + */ + } + } else { + /* No match, output a literal byte */ + //Tracevv((stderr,"%c", s.window[s.strstart])); + /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/ + bflush = _tr_tally(s, 0, s.window[s.strstart]); + + s.lookahead--; + s.strstart++; + } + if (bflush) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + } + s.insert = ((s.strstart < (MIN_MATCH - 1)) ? s.strstart : MIN_MATCH - 1); + if (flush === Z_FINISH) { + /*** FLUSH_BLOCK(s, 1); ***/ + flush_block_only(s, true); + if (s.strm.avail_out === 0) { + return BS_FINISH_STARTED; + } + /***/ + return BS_FINISH_DONE; + } + if (s.sym_next) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + return BS_BLOCK_DONE; +}; + +/* =========================================================================== + * Same as above, but achieves better compression. We use a lazy + * evaluation for matches: a match is finally adopted only if there is + * no better match at the next window position. + */ +const deflate_slow = (s, flush) => { + + let hash_head; /* head of hash chain */ + let bflush; /* set if current block must be flushed */ + + let max_insert; + + /* Process the input block. */ + for (;;) { + /* Make sure that we always have enough lookahead, except + * at the end of the input file. We need MAX_MATCH bytes + * for the next match, plus MIN_MATCH bytes to insert the + * string following the next match. + */ + if (s.lookahead < MIN_LOOKAHEAD) { + fill_window(s); + if (s.lookahead < MIN_LOOKAHEAD && flush === Z_NO_FLUSH) { + return BS_NEED_MORE; + } + if (s.lookahead === 0) { break; } /* flush the current block */ + } + + /* Insert the string window[strstart .. strstart+2] in the + * dictionary, and set hash_head to the head of the hash chain: + */ + hash_head = 0/*NIL*/; + if (s.lookahead >= MIN_MATCH) { + /*** INSERT_STRING(s, s.strstart, hash_head); ***/ + s.ins_h = HASH(s, s.ins_h, s.window[s.strstart + MIN_MATCH - 1]); + hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h]; + s.head[s.ins_h] = s.strstart; + /***/ + } + + /* Find the longest match, discarding those <= prev_length. + */ + s.prev_length = s.match_length; + s.prev_match = s.match_start; + s.match_length = MIN_MATCH - 1; + + if (hash_head !== 0/*NIL*/ && s.prev_length < s.max_lazy_match && + s.strstart - hash_head <= (s.w_size - MIN_LOOKAHEAD)/*MAX_DIST(s)*/) { + /* To simplify the code, we prevent matches with the string + * of window index 0 (in particular we have to avoid a match + * of the string with itself at the start of the input file). + */ + s.match_length = longest_match(s, hash_head); + /* longest_match() sets match_start */ + + if (s.match_length <= 5 && + (s.strategy === Z_FILTERED || (s.match_length === MIN_MATCH && s.strstart - s.match_start > 4096/*TOO_FAR*/))) { + + /* If prev_match is also MIN_MATCH, match_start is garbage + * but we will ignore the current match anyway. + */ + s.match_length = MIN_MATCH - 1; + } + } + /* If there was a match at the previous step and the current + * match is not better, output the previous match: + */ + if (s.prev_length >= MIN_MATCH && s.match_length <= s.prev_length) { + max_insert = s.strstart + s.lookahead - MIN_MATCH; + /* Do not insert strings in hash table beyond this. */ + + //check_match(s, s.strstart-1, s.prev_match, s.prev_length); + + /***_tr_tally_dist(s, s.strstart - 1 - s.prev_match, + s.prev_length - MIN_MATCH, bflush);***/ + bflush = _tr_tally(s, s.strstart - 1 - s.prev_match, s.prev_length - MIN_MATCH); + /* Insert in hash table all strings up to the end of the match. + * strstart-1 and strstart are already inserted. If there is not + * enough lookahead, the last two strings are not inserted in + * the hash table. + */ + s.lookahead -= s.prev_length - 1; + s.prev_length -= 2; + do { + if (++s.strstart <= max_insert) { + /*** INSERT_STRING(s, s.strstart, hash_head); ***/ + s.ins_h = HASH(s, s.ins_h, s.window[s.strstart + MIN_MATCH - 1]); + hash_head = s.prev[s.strstart & s.w_mask] = s.head[s.ins_h]; + s.head[s.ins_h] = s.strstart; + /***/ + } + } while (--s.prev_length !== 0); + s.match_available = 0; + s.match_length = MIN_MATCH - 1; + s.strstart++; + + if (bflush) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + + } else if (s.match_available) { + /* If there was no match at the previous position, output a + * single literal. If there was a match but the current match + * is longer, truncate the previous match to a single literal. + */ + //Tracevv((stderr,"%c", s->window[s->strstart-1])); + /*** _tr_tally_lit(s, s.window[s.strstart-1], bflush); ***/ + bflush = _tr_tally(s, 0, s.window[s.strstart - 1]); + + if (bflush) { + /*** FLUSH_BLOCK_ONLY(s, 0) ***/ + flush_block_only(s, false); + /***/ + } + s.strstart++; + s.lookahead--; + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + } else { + /* There is no previous match to compare with, wait for + * the next step to decide. + */ + s.match_available = 1; + s.strstart++; + s.lookahead--; + } + } + //Assert (flush != Z_NO_FLUSH, "no flush?"); + if (s.match_available) { + //Tracevv((stderr,"%c", s->window[s->strstart-1])); + /*** _tr_tally_lit(s, s.window[s.strstart-1], bflush); ***/ + bflush = _tr_tally(s, 0, s.window[s.strstart - 1]); + + s.match_available = 0; + } + s.insert = s.strstart < MIN_MATCH - 1 ? s.strstart : MIN_MATCH - 1; + if (flush === Z_FINISH) { + /*** FLUSH_BLOCK(s, 1); ***/ + flush_block_only(s, true); + if (s.strm.avail_out === 0) { + return BS_FINISH_STARTED; + } + /***/ + return BS_FINISH_DONE; + } + if (s.sym_next) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + + return BS_BLOCK_DONE; +}; + + +/* =========================================================================== + * For Z_RLE, simply look for runs of bytes, generate matches only of distance + * one. Do not maintain a hash table. (It will be regenerated if this run of + * deflate switches away from Z_RLE.) + */ +const deflate_rle = (s, flush) => { + + let bflush; /* set if current block must be flushed */ + let prev; /* byte at distance one to match */ + let scan, strend; /* scan goes up to strend for length of run */ + + const _win = s.window; + + for (;;) { + /* Make sure that we always have enough lookahead, except + * at the end of the input file. We need MAX_MATCH bytes + * for the longest run, plus one for the unrolled loop. + */ + if (s.lookahead <= MAX_MATCH) { + fill_window(s); + if (s.lookahead <= MAX_MATCH && flush === Z_NO_FLUSH) { + return BS_NEED_MORE; + } + if (s.lookahead === 0) { break; } /* flush the current block */ + } + + /* See how many times the previous byte repeats */ + s.match_length = 0; + if (s.lookahead >= MIN_MATCH && s.strstart > 0) { + scan = s.strstart - 1; + prev = _win[scan]; + if (prev === _win[++scan] && prev === _win[++scan] && prev === _win[++scan]) { + strend = s.strstart + MAX_MATCH; + do { + /*jshint noempty:false*/ + } while (prev === _win[++scan] && prev === _win[++scan] && + prev === _win[++scan] && prev === _win[++scan] && + prev === _win[++scan] && prev === _win[++scan] && + prev === _win[++scan] && prev === _win[++scan] && + scan < strend); + s.match_length = MAX_MATCH - (strend - scan); + if (s.match_length > s.lookahead) { + s.match_length = s.lookahead; + } + } + //Assert(scan <= s->window+(uInt)(s->window_size-1), "wild scan"); + } + + /* Emit match if have run of MIN_MATCH or longer, else emit literal */ + if (s.match_length >= MIN_MATCH) { + //check_match(s, s.strstart, s.strstart - 1, s.match_length); + + /*** _tr_tally_dist(s, 1, s.match_length - MIN_MATCH, bflush); ***/ + bflush = _tr_tally(s, 1, s.match_length - MIN_MATCH); + + s.lookahead -= s.match_length; + s.strstart += s.match_length; + s.match_length = 0; + } else { + /* No match, output a literal byte */ + //Tracevv((stderr,"%c", s->window[s->strstart])); + /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/ + bflush = _tr_tally(s, 0, s.window[s.strstart]); + + s.lookahead--; + s.strstart++; + } + if (bflush) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + } + s.insert = 0; + if (flush === Z_FINISH) { + /*** FLUSH_BLOCK(s, 1); ***/ + flush_block_only(s, true); + if (s.strm.avail_out === 0) { + return BS_FINISH_STARTED; + } + /***/ + return BS_FINISH_DONE; + } + if (s.sym_next) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + return BS_BLOCK_DONE; +}; + +/* =========================================================================== + * For Z_HUFFMAN_ONLY, do not look for matches. Do not maintain a hash table. + * (It will be regenerated if this run of deflate switches away from Huffman.) + */ +const deflate_huff = (s, flush) => { + + let bflush; /* set if current block must be flushed */ + + for (;;) { + /* Make sure that we have a literal to write. */ + if (s.lookahead === 0) { + fill_window(s); + if (s.lookahead === 0) { + if (flush === Z_NO_FLUSH) { + return BS_NEED_MORE; + } + break; /* flush the current block */ + } + } + + /* Output a literal byte */ + s.match_length = 0; + //Tracevv((stderr,"%c", s->window[s->strstart])); + /*** _tr_tally_lit(s, s.window[s.strstart], bflush); ***/ + bflush = _tr_tally(s, 0, s.window[s.strstart]); + s.lookahead--; + s.strstart++; + if (bflush) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + } + s.insert = 0; + if (flush === Z_FINISH) { + /*** FLUSH_BLOCK(s, 1); ***/ + flush_block_only(s, true); + if (s.strm.avail_out === 0) { + return BS_FINISH_STARTED; + } + /***/ + return BS_FINISH_DONE; + } + if (s.sym_next) { + /*** FLUSH_BLOCK(s, 0); ***/ + flush_block_only(s, false); + if (s.strm.avail_out === 0) { + return BS_NEED_MORE; + } + /***/ + } + return BS_BLOCK_DONE; +}; + +/* Values for max_lazy_match, good_match and max_chain_length, depending on + * the desired pack level (0..9). The values given below have been tuned to + * exclude worst case performance for pathological files. Better values may be + * found for specific files. + */ +function Config(good_length, max_lazy, nice_length, max_chain, func) { + + this.good_length = good_length; + this.max_lazy = max_lazy; + this.nice_length = nice_length; + this.max_chain = max_chain; + this.func = func; +} + +const configuration_table = [ + /* good lazy nice chain */ + new Config(0, 0, 0, 0, deflate_stored), /* 0 store only */ + new Config(4, 4, 8, 4, deflate_fast), /* 1 max speed, no lazy matches */ + new Config(4, 5, 16, 8, deflate_fast), /* 2 */ + new Config(4, 6, 32, 32, deflate_fast), /* 3 */ + + new Config(4, 4, 16, 16, deflate_slow), /* 4 lazy matches */ + new Config(8, 16, 32, 32, deflate_slow), /* 5 */ + new Config(8, 16, 128, 128, deflate_slow), /* 6 */ + new Config(8, 32, 128, 256, deflate_slow), /* 7 */ + new Config(32, 128, 258, 1024, deflate_slow), /* 8 */ + new Config(32, 258, 258, 4096, deflate_slow) /* 9 max compression */ +]; + + +/* =========================================================================== + * Initialize the "longest match" routines for a new zlib stream + */ +const lm_init = (s) => { + + s.window_size = 2 * s.w_size; + + /*** CLEAR_HASH(s); ***/ + zero(s.head); // Fill with NIL (= 0); + + /* Set the default configuration parameters: + */ + s.max_lazy_match = configuration_table[s.level].max_lazy; + s.good_match = configuration_table[s.level].good_length; + s.nice_match = configuration_table[s.level].nice_length; + s.max_chain_length = configuration_table[s.level].max_chain; + + s.strstart = 0; + s.block_start = 0; + s.lookahead = 0; + s.insert = 0; + s.match_length = s.prev_length = MIN_MATCH - 1; + s.match_available = 0; + s.ins_h = 0; +}; + + +function DeflateState() { + this.strm = null; /* pointer back to this zlib stream */ + this.status = 0; /* as the name implies */ + this.pending_buf = null; /* output still pending */ + this.pending_buf_size = 0; /* size of pending_buf */ + this.pending_out = 0; /* next pending byte to output to the stream */ + this.pending = 0; /* nb of bytes in the pending buffer */ + this.wrap = 0; /* bit 0 true for zlib, bit 1 true for gzip */ + this.gzhead = null; /* gzip header information to write */ + this.gzindex = 0; /* where in extra, name, or comment */ + this.method = Z_DEFLATED; /* can only be DEFLATED */ + this.last_flush = -1; /* value of flush param for previous deflate call */ + + this.w_size = 0; /* LZ77 window size (32K by default) */ + this.w_bits = 0; /* log2(w_size) (8..16) */ + this.w_mask = 0; /* w_size - 1 */ + + this.window = null; + /* Sliding window. Input bytes are read into the second half of the window, + * and move to the first half later to keep a dictionary of at least wSize + * bytes. With this organization, matches are limited to a distance of + * wSize-MAX_MATCH bytes, but this ensures that IO is always + * performed with a length multiple of the block size. + */ + + this.window_size = 0; + /* Actual size of window: 2*wSize, except when the user input buffer + * is directly used as sliding window. + */ + + this.prev = null; + /* Link to older string with same hash index. To limit the size of this + * array to 64K, this link is maintained only for the last 32K strings. + * An index in this array is thus a window index modulo 32K. + */ + + this.head = null; /* Heads of the hash chains or NIL. */ + + this.ins_h = 0; /* hash index of string to be inserted */ + this.hash_size = 0; /* number of elements in hash table */ + this.hash_bits = 0; /* log2(hash_size) */ + this.hash_mask = 0; /* hash_size-1 */ + + this.hash_shift = 0; + /* Number of bits by which ins_h must be shifted at each input + * step. It must be such that after MIN_MATCH steps, the oldest + * byte no longer takes part in the hash key, that is: + * hash_shift * MIN_MATCH >= hash_bits + */ + + this.block_start = 0; + /* Window position at the beginning of the current output block. Gets + * negative when the window is moved backwards. + */ + + this.match_length = 0; /* length of best match */ + this.prev_match = 0; /* previous match */ + this.match_available = 0; /* set if previous match exists */ + this.strstart = 0; /* start of string to insert */ + this.match_start = 0; /* start of matching string */ + this.lookahead = 0; /* number of valid bytes ahead in window */ + + this.prev_length = 0; + /* Length of the best match at previous step. Matches not greater than this + * are discarded. This is used in the lazy match evaluation. + */ + + this.max_chain_length = 0; + /* To speed up deflation, hash chains are never searched beyond this + * length. A higher limit improves compression ratio but degrades the + * speed. + */ + + this.max_lazy_match = 0; + /* Attempt to find a better match only when the current match is strictly + * smaller than this value. This mechanism is used only for compression + * levels >= 4. + */ + // That's alias to max_lazy_match, don't use directly + //this.max_insert_length = 0; + /* Insert new strings in the hash table only if the match length is not + * greater than this length. This saves time but degrades compression. + * max_insert_length is used only for compression levels <= 3. + */ + + this.level = 0; /* compression level (1..9) */ + this.strategy = 0; /* favor or force Huffman coding*/ + + this.good_match = 0; + /* Use a faster search when the previous match is longer than this */ + + this.nice_match = 0; /* Stop searching when current match exceeds this */ + + /* used by trees.c: */ + + /* Didn't use ct_data typedef below to suppress compiler warning */ + + // struct ct_data_s dyn_ltree[HEAP_SIZE]; /* literal and length tree */ + // struct ct_data_s dyn_dtree[2*D_CODES+1]; /* distance tree */ + // struct ct_data_s bl_tree[2*BL_CODES+1]; /* Huffman tree for bit lengths */ + + // Use flat array of DOUBLE size, with interleaved fata, + // because JS does not support effective + this.dyn_ltree = new Uint16Array(HEAP_SIZE * 2); + this.dyn_dtree = new Uint16Array((2 * D_CODES + 1) * 2); + this.bl_tree = new Uint16Array((2 * BL_CODES + 1) * 2); + zero(this.dyn_ltree); + zero(this.dyn_dtree); + zero(this.bl_tree); + + this.l_desc = null; /* desc. for literal tree */ + this.d_desc = null; /* desc. for distance tree */ + this.bl_desc = null; /* desc. for bit length tree */ + + //ush bl_count[MAX_BITS+1]; + this.bl_count = new Uint16Array(MAX_BITS + 1); + /* number of codes at each bit length for an optimal tree */ + + //int heap[2*L_CODES+1]; /* heap used to build the Huffman trees */ + this.heap = new Uint16Array(2 * L_CODES + 1); /* heap used to build the Huffman trees */ + zero(this.heap); + + this.heap_len = 0; /* number of elements in the heap */ + this.heap_max = 0; /* element of largest frequency */ + /* The sons of heap[n] are heap[2*n] and heap[2*n+1]. heap[0] is not used. + * The same heap array is used to build all trees. + */ + + this.depth = new Uint16Array(2 * L_CODES + 1); //uch depth[2*L_CODES+1]; + zero(this.depth); + /* Depth of each subtree used as tie breaker for trees of equal frequency + */ + + this.sym_buf = 0; /* buffer for distances and literals/lengths */ + + this.lit_bufsize = 0; + /* Size of match buffer for literals/lengths. There are 4 reasons for + * limiting lit_bufsize to 64K: + * - frequencies can be kept in 16 bit counters + * - if compression is not successful for the first block, all input + * data is still in the window so we can still emit a stored block even + * when input comes from standard input. (This can also be done for + * all blocks if lit_bufsize is not greater than 32K.) + * - if compression is not successful for a file smaller than 64K, we can + * even emit a stored file instead of a stored block (saving 5 bytes). + * This is applicable only for zip (not gzip or zlib). + * - creating new Huffman trees less frequently may not provide fast + * adaptation to changes in the input data statistics. (Take for + * example a binary file with poorly compressible code followed by + * a highly compressible string table.) Smaller buffer sizes give + * fast adaptation but have of course the overhead of transmitting + * trees more frequently. + * - I can't count above 4 + */ + + this.sym_next = 0; /* running index in sym_buf */ + this.sym_end = 0; /* symbol table full when sym_next reaches this */ + + this.opt_len = 0; /* bit length of current block with optimal trees */ + this.static_len = 0; /* bit length of current block with static trees */ + this.matches = 0; /* number of string matches in current block */ + this.insert = 0; /* bytes at end of window left to insert */ + + + this.bi_buf = 0; + /* Output buffer. bits are inserted starting at the bottom (least + * significant bits). + */ + this.bi_valid = 0; + /* Number of valid bits in bi_buf. All bits above the last valid bit + * are always zero. + */ + + // Used for window memory init. We safely ignore it for JS. That makes + // sense only for pointers and memory check tools. + //this.high_water = 0; + /* High water mark offset in window for initialized bytes -- bytes above + * this are set to zero in order to avoid memory check warnings when + * longest match routines access bytes past the input. This is then + * updated to the new high water mark. + */ +} + + +/* ========================================================================= + * Check for a valid deflate stream state. Return 0 if ok, 1 if not. + */ +const deflateStateCheck = (strm) => { + + if (!strm) { + return 1; + } + const s = strm.state; + if (!s || s.strm !== strm || (s.status !== INIT_STATE && +//#ifdef GZIP + s.status !== GZIP_STATE && +//#endif + s.status !== EXTRA_STATE && + s.status !== NAME_STATE && + s.status !== COMMENT_STATE && + s.status !== HCRC_STATE && + s.status !== BUSY_STATE && + s.status !== FINISH_STATE)) { + return 1; + } + return 0; +}; + + +const deflateResetKeep = (strm) => { + + if (deflateStateCheck(strm)) { + return err(strm, Z_STREAM_ERROR); + } + + strm.total_in = strm.total_out = 0; + strm.data_type = Z_UNKNOWN; + + const s = strm.state; + s.pending = 0; + s.pending_out = 0; + + if (s.wrap < 0) { + s.wrap = -s.wrap; + /* was made negative by deflate(..., Z_FINISH); */ + } + s.status = +//#ifdef GZIP + s.wrap === 2 ? GZIP_STATE : +//#endif + s.wrap ? INIT_STATE : BUSY_STATE; + strm.adler = (s.wrap === 2) ? + 0 // crc32(0, Z_NULL, 0) + : + 1; // adler32(0, Z_NULL, 0) + s.last_flush = -2; + _tr_init(s); + return Z_OK; +}; + + +const deflateReset = (strm) => { + + const ret = deflateResetKeep(strm); + if (ret === Z_OK) { + lm_init(strm.state); + } + return ret; +}; + + +const deflateSetHeader = (strm, head) => { + + if (deflateStateCheck(strm) || strm.state.wrap !== 2) { + return Z_STREAM_ERROR; + } + strm.state.gzhead = head; + return Z_OK; +}; + + +const deflateInit2 = (strm, level, method, windowBits, memLevel, strategy) => { + + if (!strm) { // === Z_NULL + return Z_STREAM_ERROR; + } + let wrap = 1; + + if (level === Z_DEFAULT_COMPRESSION) { + level = 6; + } + + if (windowBits < 0) { /* suppress zlib wrapper */ + wrap = 0; + windowBits = -windowBits; + } + + else if (windowBits > 15) { + wrap = 2; /* write gzip wrapper instead */ + windowBits -= 16; + } + + + if (memLevel < 1 || memLevel > MAX_MEM_LEVEL || method !== Z_DEFLATED || + windowBits < 8 || windowBits > 15 || level < 0 || level > 9 || + strategy < 0 || strategy > Z_FIXED || (windowBits === 8 && wrap !== 1)) { + return err(strm, Z_STREAM_ERROR); + } + + + if (windowBits === 8) { + windowBits = 9; + } + /* until 256-byte window bug fixed */ + + const s = new DeflateState(); + + strm.state = s; + s.strm = strm; + s.status = INIT_STATE; /* to pass state test in deflateReset() */ + + s.wrap = wrap; + s.gzhead = null; + s.w_bits = windowBits; + s.w_size = 1 << s.w_bits; + s.w_mask = s.w_size - 1; + + s.hash_bits = memLevel + 7; + s.hash_size = 1 << s.hash_bits; + s.hash_mask = s.hash_size - 1; + s.hash_shift = ~~((s.hash_bits + MIN_MATCH - 1) / MIN_MATCH); + + s.window = new Uint8Array(s.w_size * 2); + s.head = new Uint16Array(s.hash_size); + s.prev = new Uint16Array(s.w_size); + + // Don't need mem init magic for JS. + //s.high_water = 0; /* nothing written to s->window yet */ + + s.lit_bufsize = 1 << (memLevel + 6); /* 16K elements by default */ + + /* We overlay pending_buf and sym_buf. This works since the average size + * for length/distance pairs over any compressed block is assured to be 31 + * bits or less. + * + * Analysis: The longest fixed codes are a length code of 8 bits plus 5 + * extra bits, for lengths 131 to 257. The longest fixed distance codes are + * 5 bits plus 13 extra bits, for distances 16385 to 32768. The longest + * possible fixed-codes length/distance pair is then 31 bits total. + * + * sym_buf starts one-fourth of the way into pending_buf. So there are + * three bytes in sym_buf for every four bytes in pending_buf. Each symbol + * in sym_buf is three bytes -- two for the distance and one for the + * literal/length. As each symbol is consumed, the pointer to the next + * sym_buf value to read moves forward three bytes. From that symbol, up to + * 31 bits are written to pending_buf. The closest the written pending_buf + * bits gets to the next sym_buf symbol to read is just before the last + * code is written. At that time, 31*(n-2) bits have been written, just + * after 24*(n-2) bits have been consumed from sym_buf. sym_buf starts at + * 8*n bits into pending_buf. (Note that the symbol buffer fills when n-1 + * symbols are written.) The closest the writing gets to what is unread is + * then n+14 bits. Here n is lit_bufsize, which is 16384 by default, and + * can range from 128 to 32768. + * + * Therefore, at a minimum, there are 142 bits of space between what is + * written and what is read in the overlain buffers, so the symbols cannot + * be overwritten by the compressed data. That space is actually 139 bits, + * due to the three-bit fixed-code block header. + * + * That covers the case where either Z_FIXED is specified, forcing fixed + * codes, or when the use of fixed codes is chosen, because that choice + * results in a smaller compressed block than dynamic codes. That latter + * condition then assures that the above analysis also covers all dynamic + * blocks. A dynamic-code block will only be chosen to be emitted if it has + * fewer bits than a fixed-code block would for the same set of symbols. + * Therefore its average symbol length is assured to be less than 31. So + * the compressed data for a dynamic block also cannot overwrite the + * symbols from which it is being constructed. + */ + + s.pending_buf_size = s.lit_bufsize * 4; + s.pending_buf = new Uint8Array(s.pending_buf_size); + + // It is offset from `s.pending_buf` (size is `s.lit_bufsize * 2`) + //s->sym_buf = s->pending_buf + s->lit_bufsize; + s.sym_buf = s.lit_bufsize; + + //s->sym_end = (s->lit_bufsize - 1) * 3; + s.sym_end = (s.lit_bufsize - 1) * 3; + /* We avoid equality with lit_bufsize*3 because of wraparound at 64K + * on 16 bit machines and because stored blocks are restricted to + * 64K-1 bytes. + */ + + s.level = level; + s.strategy = strategy; + s.method = method; + + return deflateReset(strm); +}; + +const deflateInit = (strm, level) => { + + return deflateInit2(strm, level, Z_DEFLATED, MAX_WBITS, DEF_MEM_LEVEL, Z_DEFAULT_STRATEGY); +}; + + +/* ========================================================================= */ +const deflate = (strm, flush) => { + + if (deflateStateCheck(strm) || flush > Z_BLOCK || flush < 0) { + return strm ? err(strm, Z_STREAM_ERROR) : Z_STREAM_ERROR; + } + + const s = strm.state; + + if (!strm.output || + (strm.avail_in !== 0 && !strm.input) || + (s.status === FINISH_STATE && flush !== Z_FINISH)) { + return err(strm, (strm.avail_out === 0) ? Z_BUF_ERROR : Z_STREAM_ERROR); + } + + const old_flush = s.last_flush; + s.last_flush = flush; + + /* Flush as much pending output as possible */ + if (s.pending !== 0) { + flush_pending(strm); + if (strm.avail_out === 0) { + /* Since avail_out is 0, deflate will be called again with + * more output space, but possibly with both pending and + * avail_in equal to zero. There won't be anything to do, + * but this is not an error situation so make sure we + * return OK instead of BUF_ERROR at next call of deflate: + */ + s.last_flush = -1; + return Z_OK; + } + + /* Make sure there is something to do and avoid duplicate consecutive + * flushes. For repeated and useless calls with Z_FINISH, we keep + * returning Z_STREAM_END instead of Z_BUF_ERROR. + */ + } else if (strm.avail_in === 0 && rank(flush) <= rank(old_flush) && + flush !== Z_FINISH) { + return err(strm, Z_BUF_ERROR); + } + + /* User must not provide more input after the first FINISH: */ + if (s.status === FINISH_STATE && strm.avail_in !== 0) { + return err(strm, Z_BUF_ERROR); + } + + /* Write the header */ + if (s.status === INIT_STATE && s.wrap === 0) { + s.status = BUSY_STATE; + } + if (s.status === INIT_STATE) { + /* zlib header */ + let header = (Z_DEFLATED + ((s.w_bits - 8) << 4)) << 8; + let level_flags = -1; + + if (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2) { + level_flags = 0; + } else if (s.level < 6) { + level_flags = 1; + } else if (s.level === 6) { + level_flags = 2; + } else { + level_flags = 3; + } + header |= (level_flags << 6); + if (s.strstart !== 0) { header |= PRESET_DICT; } + header += 31 - (header % 31); + + putShortMSB(s, header); + + /* Save the adler32 of the preset dictionary: */ + if (s.strstart !== 0) { + putShortMSB(s, strm.adler >>> 16); + putShortMSB(s, strm.adler & 0xffff); + } + strm.adler = 1; // adler32(0L, Z_NULL, 0); + s.status = BUSY_STATE; + + /* Compression must start with an empty pending buffer */ + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + } +//#ifdef GZIP + if (s.status === GZIP_STATE) { + /* gzip header */ + strm.adler = 0; //crc32(0L, Z_NULL, 0); + put_byte(s, 31); + put_byte(s, 139); + put_byte(s, 8); + if (!s.gzhead) { // s->gzhead == Z_NULL + put_byte(s, 0); + put_byte(s, 0); + put_byte(s, 0); + put_byte(s, 0); + put_byte(s, 0); + put_byte(s, s.level === 9 ? 2 : + (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2 ? + 4 : 0)); + put_byte(s, OS_CODE); + s.status = BUSY_STATE; + + /* Compression must start with an empty pending buffer */ + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + } + else { + put_byte(s, (s.gzhead.text ? 1 : 0) + + (s.gzhead.hcrc ? 2 : 0) + + (!s.gzhead.extra ? 0 : 4) + + (!s.gzhead.name ? 0 : 8) + + (!s.gzhead.comment ? 0 : 16) + ); + put_byte(s, s.gzhead.time & 0xff); + put_byte(s, (s.gzhead.time >> 8) & 0xff); + put_byte(s, (s.gzhead.time >> 16) & 0xff); + put_byte(s, (s.gzhead.time >> 24) & 0xff); + put_byte(s, s.level === 9 ? 2 : + (s.strategy >= Z_HUFFMAN_ONLY || s.level < 2 ? + 4 : 0)); + put_byte(s, s.gzhead.os & 0xff); + if (s.gzhead.extra && s.gzhead.extra.length) { + put_byte(s, s.gzhead.extra.length & 0xff); + put_byte(s, (s.gzhead.extra.length >> 8) & 0xff); + } + if (s.gzhead.hcrc) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending, 0); + } + s.gzindex = 0; + s.status = EXTRA_STATE; + } + } + if (s.status === EXTRA_STATE) { + if (s.gzhead.extra/* != Z_NULL*/) { + let beg = s.pending; /* start of bytes to update crc */ + let left = (s.gzhead.extra.length & 0xffff) - s.gzindex; + while (s.pending + left > s.pending_buf_size) { + let copy = s.pending_buf_size - s.pending; + // zmemcpy(s.pending_buf + s.pending, + // s.gzhead.extra + s.gzindex, copy); + s.pending_buf.set(s.gzhead.extra.subarray(s.gzindex, s.gzindex + copy), s.pending); + s.pending = s.pending_buf_size; + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + s.gzindex += copy; + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + beg = 0; + left -= copy; + } + // JS specific: s.gzhead.extra may be TypedArray or Array for backward compatibility + // TypedArray.slice and TypedArray.from don't exist in IE10-IE11 + let gzhead_extra = new Uint8Array(s.gzhead.extra); + // zmemcpy(s->pending_buf + s->pending, + // s->gzhead->extra + s->gzindex, left); + s.pending_buf.set(gzhead_extra.subarray(s.gzindex, s.gzindex + left), s.pending); + s.pending += left; + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + s.gzindex = 0; + } + s.status = NAME_STATE; + } + if (s.status === NAME_STATE) { + if (s.gzhead.name/* != Z_NULL*/) { + let beg = s.pending; /* start of bytes to update crc */ + let val; + do { + if (s.pending === s.pending_buf_size) { + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + beg = 0; + } + // JS specific: little magic to add zero terminator to end of string + if (s.gzindex < s.gzhead.name.length) { + val = s.gzhead.name.charCodeAt(s.gzindex++) & 0xff; + } else { + val = 0; + } + put_byte(s, val); + } while (val !== 0); + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + s.gzindex = 0; + } + s.status = COMMENT_STATE; + } + if (s.status === COMMENT_STATE) { + if (s.gzhead.comment/* != Z_NULL*/) { + let beg = s.pending; /* start of bytes to update crc */ + let val; + do { + if (s.pending === s.pending_buf_size) { + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + beg = 0; + } + // JS specific: little magic to add zero terminator to end of string + if (s.gzindex < s.gzhead.comment.length) { + val = s.gzhead.comment.charCodeAt(s.gzindex++) & 0xff; + } else { + val = 0; + } + put_byte(s, val); + } while (val !== 0); + //--- HCRC_UPDATE(beg) ---// + if (s.gzhead.hcrc && s.pending > beg) { + strm.adler = crc32(strm.adler, s.pending_buf, s.pending - beg, beg); + } + //---// + } + s.status = HCRC_STATE; + } + if (s.status === HCRC_STATE) { + if (s.gzhead.hcrc) { + if (s.pending + 2 > s.pending_buf_size) { + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + } + put_byte(s, strm.adler & 0xff); + put_byte(s, (strm.adler >> 8) & 0xff); + strm.adler = 0; //crc32(0L, Z_NULL, 0); + } + s.status = BUSY_STATE; + + /* Compression must start with an empty pending buffer */ + flush_pending(strm); + if (s.pending !== 0) { + s.last_flush = -1; + return Z_OK; + } + } +//#endif + + /* Start a new block or continue the current one. + */ + if (strm.avail_in !== 0 || s.lookahead !== 0 || + (flush !== Z_NO_FLUSH && s.status !== FINISH_STATE)) { + let bstate = s.level === 0 ? deflate_stored(s, flush) : + s.strategy === Z_HUFFMAN_ONLY ? deflate_huff(s, flush) : + s.strategy === Z_RLE ? deflate_rle(s, flush) : + configuration_table[s.level].func(s, flush); + + if (bstate === BS_FINISH_STARTED || bstate === BS_FINISH_DONE) { + s.status = FINISH_STATE; + } + if (bstate === BS_NEED_MORE || bstate === BS_FINISH_STARTED) { + if (strm.avail_out === 0) { + s.last_flush = -1; + /* avoid BUF_ERROR next call, see above */ + } + return Z_OK; + /* If flush != Z_NO_FLUSH && avail_out == 0, the next call + * of deflate should use the same flush parameter to make sure + * that the flush is complete. So we don't have to output an + * empty block here, this will be done at next call. This also + * ensures that for a very small output buffer, we emit at most + * one empty block. + */ + } + if (bstate === BS_BLOCK_DONE) { + if (flush === Z_PARTIAL_FLUSH) { + _tr_align(s); + } + else if (flush !== Z_BLOCK) { /* FULL_FLUSH or SYNC_FLUSH */ + + _tr_stored_block(s, 0, 0, false); + /* For a full flush, this empty block will be recognized + * as a special marker by inflate_sync(). + */ + if (flush === Z_FULL_FLUSH) { + /*** CLEAR_HASH(s); ***/ /* forget history */ + zero(s.head); // Fill with NIL (= 0); + + if (s.lookahead === 0) { + s.strstart = 0; + s.block_start = 0; + s.insert = 0; + } + } + } + flush_pending(strm); + if (strm.avail_out === 0) { + s.last_flush = -1; /* avoid BUF_ERROR at next call, see above */ + return Z_OK; + } + } + } + + if (flush !== Z_FINISH) { return Z_OK; } + if (s.wrap <= 0) { return Z_STREAM_END; } + + /* Write the trailer */ + if (s.wrap === 2) { + put_byte(s, strm.adler & 0xff); + put_byte(s, (strm.adler >> 8) & 0xff); + put_byte(s, (strm.adler >> 16) & 0xff); + put_byte(s, (strm.adler >> 24) & 0xff); + put_byte(s, strm.total_in & 0xff); + put_byte(s, (strm.total_in >> 8) & 0xff); + put_byte(s, (strm.total_in >> 16) & 0xff); + put_byte(s, (strm.total_in >> 24) & 0xff); + } + else + { + putShortMSB(s, strm.adler >>> 16); + putShortMSB(s, strm.adler & 0xffff); + } + + flush_pending(strm); + /* If avail_out is zero, the application will call deflate again + * to flush the rest. + */ + if (s.wrap > 0) { s.wrap = -s.wrap; } + /* write the trailer only once! */ + return s.pending !== 0 ? Z_OK : Z_STREAM_END; +}; + + +const deflateEnd = (strm) => { + + if (deflateStateCheck(strm)) { + return Z_STREAM_ERROR; + } + + const status = strm.state.status; + + strm.state = null; + + return status === BUSY_STATE ? err(strm, Z_DATA_ERROR) : Z_OK; +}; + + +/* ========================================================================= + * Initializes the compression dictionary from the given byte + * sequence without producing any compressed output. + */ +const deflateSetDictionary = (strm, dictionary) => { + + let dictLength = dictionary.length; + + if (deflateStateCheck(strm)) { + return Z_STREAM_ERROR; + } + + const s = strm.state; + const wrap = s.wrap; + + if (wrap === 2 || (wrap === 1 && s.status !== INIT_STATE) || s.lookahead) { + return Z_STREAM_ERROR; + } + + /* when using zlib wrappers, compute Adler-32 for provided dictionary */ + if (wrap === 1) { + /* adler32(strm->adler, dictionary, dictLength); */ + strm.adler = adler32(strm.adler, dictionary, dictLength, 0); + } + + s.wrap = 0; /* avoid computing Adler-32 in read_buf */ + + /* if dictionary would fill window, just replace the history */ + if (dictLength >= s.w_size) { + if (wrap === 0) { /* already empty otherwise */ + /*** CLEAR_HASH(s); ***/ + zero(s.head); // Fill with NIL (= 0); + s.strstart = 0; + s.block_start = 0; + s.insert = 0; + } + /* use the tail */ + // dictionary = dictionary.slice(dictLength - s.w_size); + let tmpDict = new Uint8Array(s.w_size); + tmpDict.set(dictionary.subarray(dictLength - s.w_size, dictLength), 0); + dictionary = tmpDict; + dictLength = s.w_size; + } + /* insert dictionary into window and hash */ + const avail = strm.avail_in; + const next = strm.next_in; + const input = strm.input; + strm.avail_in = dictLength; + strm.next_in = 0; + strm.input = dictionary; + fill_window(s); + while (s.lookahead >= MIN_MATCH) { + let str = s.strstart; + let n = s.lookahead - (MIN_MATCH - 1); + do { + /* UPDATE_HASH(s, s->ins_h, s->window[str + MIN_MATCH-1]); */ + s.ins_h = HASH(s, s.ins_h, s.window[str + MIN_MATCH - 1]); + + s.prev[str & s.w_mask] = s.head[s.ins_h]; + + s.head[s.ins_h] = str; + str++; + } while (--n); + s.strstart = str; + s.lookahead = MIN_MATCH - 1; + fill_window(s); + } + s.strstart += s.lookahead; + s.block_start = s.strstart; + s.insert = s.lookahead; + s.lookahead = 0; + s.match_length = s.prev_length = MIN_MATCH - 1; + s.match_available = 0; + strm.next_in = next; + strm.input = input; + strm.avail_in = avail; + s.wrap = wrap; + return Z_OK; +}; + + +module.exports.deflateInit = deflateInit; +module.exports.deflateInit2 = deflateInit2; +module.exports.deflateReset = deflateReset; +module.exports.deflateResetKeep = deflateResetKeep; +module.exports.deflateSetHeader = deflateSetHeader; +module.exports.deflate = deflate; +module.exports.deflateEnd = deflateEnd; +module.exports.deflateSetDictionary = deflateSetDictionary; +module.exports.deflateInfo = 'pako deflate (from Nodeca project)'; + +/* Not implemented +module.exports.deflateBound = deflateBound; +module.exports.deflateCopy = deflateCopy; +module.exports.deflateGetDictionary = deflateGetDictionary; +module.exports.deflateParams = deflateParams; +module.exports.deflatePending = deflatePending; +module.exports.deflatePrime = deflatePrime; +module.exports.deflateTune = deflateTune; +*/ diff --git a/blue_modules/pako/lib/zlib/gzheader.js b/blue_modules/pako/lib/zlib/gzheader.js new file mode 100644 index 00000000000..9582cba6032 --- /dev/null +++ b/blue_modules/pako/lib/zlib/gzheader.js @@ -0,0 +1,58 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +function GZheader() { + /* true if compressed data believed to be text */ + this.text = 0; + /* modification time */ + this.time = 0; + /* extra flags (not used when writing a gzip file) */ + this.xflags = 0; + /* operating system */ + this.os = 0; + /* pointer to extra field or Z_NULL if none */ + this.extra = null; + /* extra field length (valid if extra != Z_NULL) */ + this.extra_len = 0; // Actually, we don't need it in JS, + // but leave for few code modifications + + // + // Setup limits is not necessary because in js we should not preallocate memory + // for inflate use constant limit in 65536 bytes + // + + /* space at extra (only when reading header) */ + // this.extra_max = 0; + /* pointer to zero-terminated file name or Z_NULL */ + this.name = ''; + /* space at name (only when reading header) */ + // this.name_max = 0; + /* pointer to zero-terminated comment or Z_NULL */ + this.comment = ''; + /* space at comment (only when reading header) */ + // this.comm_max = 0; + /* true if there was or will be a header crc */ + this.hcrc = 0; + /* true when done reading gzip header (not used when writing a gzip file) */ + this.done = false; +} + +module.exports = GZheader; diff --git a/blue_modules/pako/lib/zlib/inffast.js b/blue_modules/pako/lib/zlib/inffast.js new file mode 100644 index 00000000000..f4d6e7e4538 --- /dev/null +++ b/blue_modules/pako/lib/zlib/inffast.js @@ -0,0 +1,344 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +// See state defs from inflate.js +const BAD = 16209; /* got a data error -- remain here until reset */ +const TYPE = 16191; /* i: waiting for type bits, including last-flag bit */ + +/* + Decode literal, length, and distance codes and write out the resulting + literal and match bytes until either not enough input or output is + available, an end-of-block is encountered, or a data error is encountered. + When large enough input and output buffers are supplied to inflate(), for + example, a 16K input buffer and a 64K output buffer, more than 95% of the + inflate execution time is spent in this routine. + + Entry assumptions: + + state.mode === LEN + strm.avail_in >= 6 + strm.avail_out >= 258 + start >= strm.avail_out + state.bits < 8 + + On return, state.mode is one of: + + LEN -- ran out of enough output space or enough available input + TYPE -- reached end of block code, inflate() to interpret next block + BAD -- error in block data + + Notes: + + - The maximum input bits used by a length/distance pair is 15 bits for the + length code, 5 bits for the length extra, 15 bits for the distance code, + and 13 bits for the distance extra. This totals 48 bits, or six bytes. + Therefore if strm.avail_in >= 6, then there is enough input to avoid + checking for available input while decoding. + + - The maximum bytes that a single length/distance pair can output is 258 + bytes, which is the maximum length that can be coded. inflate_fast() + requires strm.avail_out >= 258 for each loop to avoid checking for + output space. + */ +module.exports = function inflate_fast(strm, start) { + let _in; /* local strm.input */ + let last; /* have enough input while in < last */ + let _out; /* local strm.output */ + let beg; /* inflate()'s initial strm.output */ + let end; /* while out < end, enough space available */ +//#ifdef INFLATE_STRICT + let dmax; /* maximum distance from zlib header */ +//#endif + let wsize; /* window size or zero if not using window */ + let whave; /* valid bytes in the window */ + let wnext; /* window write index */ + // Use `s_window` instead `window`, avoid conflict with instrumentation tools + let s_window; /* allocated sliding window, if wsize != 0 */ + let hold; /* local strm.hold */ + let bits; /* local strm.bits */ + let lcode; /* local strm.lencode */ + let dcode; /* local strm.distcode */ + let lmask; /* mask for first level of length codes */ + let dmask; /* mask for first level of distance codes */ + let here; /* retrieved table entry */ + let op; /* code bits, operation, extra bits, or */ + /* window position, window bytes to copy */ + let len; /* match length, unused bytes */ + let dist; /* match distance */ + let from; /* where to copy match from */ + let from_source; + + + let input, output; // JS specific, because we have no pointers + + /* copy state to local variables */ + const state = strm.state; + //here = state.here; + _in = strm.next_in; + input = strm.input; + last = _in + (strm.avail_in - 5); + _out = strm.next_out; + output = strm.output; + beg = _out - (start - strm.avail_out); + end = _out + (strm.avail_out - 257); +//#ifdef INFLATE_STRICT + dmax = state.dmax; +//#endif + wsize = state.wsize; + whave = state.whave; + wnext = state.wnext; + s_window = state.window; + hold = state.hold; + bits = state.bits; + lcode = state.lencode; + dcode = state.distcode; + lmask = (1 << state.lenbits) - 1; + dmask = (1 << state.distbits) - 1; + + + /* decode literals and length/distances until end-of-block or not enough + input data or output space */ + + top: + do { + if (bits < 15) { + hold += input[_in++] << bits; + bits += 8; + hold += input[_in++] << bits; + bits += 8; + } + + here = lcode[hold & lmask]; + + dolen: + for (;;) { // Goto emulation + op = here >>> 24/*here.bits*/; + hold >>>= op; + bits -= op; + op = (here >>> 16) & 0xff/*here.op*/; + if (op === 0) { /* literal */ + //Tracevv((stderr, here.val >= 0x20 && here.val < 0x7f ? + // "inflate: literal '%c'\n" : + // "inflate: literal 0x%02x\n", here.val)); + output[_out++] = here & 0xffff/*here.val*/; + } + else if (op & 16) { /* length base */ + len = here & 0xffff/*here.val*/; + op &= 15; /* number of extra bits */ + if (op) { + if (bits < op) { + hold += input[_in++] << bits; + bits += 8; + } + len += hold & ((1 << op) - 1); + hold >>>= op; + bits -= op; + } + //Tracevv((stderr, "inflate: length %u\n", len)); + if (bits < 15) { + hold += input[_in++] << bits; + bits += 8; + hold += input[_in++] << bits; + bits += 8; + } + here = dcode[hold & dmask]; + + dodist: + for (;;) { // goto emulation + op = here >>> 24/*here.bits*/; + hold >>>= op; + bits -= op; + op = (here >>> 16) & 0xff/*here.op*/; + + if (op & 16) { /* distance base */ + dist = here & 0xffff/*here.val*/; + op &= 15; /* number of extra bits */ + if (bits < op) { + hold += input[_in++] << bits; + bits += 8; + if (bits < op) { + hold += input[_in++] << bits; + bits += 8; + } + } + dist += hold & ((1 << op) - 1); +//#ifdef INFLATE_STRICT + if (dist > dmax) { + strm.msg = 'invalid distance too far back'; + state.mode = BAD; + break top; + } +//#endif + hold >>>= op; + bits -= op; + //Tracevv((stderr, "inflate: distance %u\n", dist)); + op = _out - beg; /* max distance in output */ + if (dist > op) { /* see if copy from window */ + op = dist - op; /* distance back in window */ + if (op > whave) { + if (state.sane) { + strm.msg = 'invalid distance too far back'; + state.mode = BAD; + break top; + } + +// (!) This block is disabled in zlib defaults, +// don't enable it for binary compatibility +//#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR +// if (len <= op - whave) { +// do { +// output[_out++] = 0; +// } while (--len); +// continue top; +// } +// len -= op - whave; +// do { +// output[_out++] = 0; +// } while (--op > whave); +// if (op === 0) { +// from = _out - dist; +// do { +// output[_out++] = output[from++]; +// } while (--len); +// continue top; +// } +//#endif + } + from = 0; // window index + from_source = s_window; + if (wnext === 0) { /* very common case */ + from += wsize - op; + if (op < len) { /* some from window */ + len -= op; + do { + output[_out++] = s_window[from++]; + } while (--op); + from = _out - dist; /* rest from output */ + from_source = output; + } + } + else if (wnext < op) { /* wrap around window */ + from += wsize + wnext - op; + op -= wnext; + if (op < len) { /* some from end of window */ + len -= op; + do { + output[_out++] = s_window[from++]; + } while (--op); + from = 0; + if (wnext < len) { /* some from start of window */ + op = wnext; + len -= op; + do { + output[_out++] = s_window[from++]; + } while (--op); + from = _out - dist; /* rest from output */ + from_source = output; + } + } + } + else { /* contiguous in window */ + from += wnext - op; + if (op < len) { /* some from window */ + len -= op; + do { + output[_out++] = s_window[from++]; + } while (--op); + from = _out - dist; /* rest from output */ + from_source = output; + } + } + while (len > 2) { + output[_out++] = from_source[from++]; + output[_out++] = from_source[from++]; + output[_out++] = from_source[from++]; + len -= 3; + } + if (len) { + output[_out++] = from_source[from++]; + if (len > 1) { + output[_out++] = from_source[from++]; + } + } + } + else { + from = _out - dist; /* copy direct from output */ + do { /* minimum length is three */ + output[_out++] = output[from++]; + output[_out++] = output[from++]; + output[_out++] = output[from++]; + len -= 3; + } while (len > 2); + if (len) { + output[_out++] = output[from++]; + if (len > 1) { + output[_out++] = output[from++]; + } + } + } + } + else if ((op & 64) === 0) { /* 2nd level distance code */ + here = dcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))]; + continue dodist; + } + else { + strm.msg = 'invalid distance code'; + state.mode = BAD; + break top; + } + + break; // need to emulate goto via "continue" + } + } + else if ((op & 64) === 0) { /* 2nd level length code */ + here = lcode[(here & 0xffff)/*here.val*/ + (hold & ((1 << op) - 1))]; + continue dolen; + } + else if (op & 32) { /* end-of-block */ + //Tracevv((stderr, "inflate: end of block\n")); + state.mode = TYPE; + break top; + } + else { + strm.msg = 'invalid literal/length code'; + state.mode = BAD; + break top; + } + + break; // need to emulate goto via "continue" + } + } while (_in < last && _out < end); + + /* return unused bytes (on entry, bits < 8, so in won't go too far back) */ + len = bits >> 3; + _in -= len; + bits -= len << 3; + hold &= (1 << bits) - 1; + + /* update state and return */ + strm.next_in = _in; + strm.next_out = _out; + strm.avail_in = (_in < last ? 5 + (last - _in) : 5 - (_in - last)); + strm.avail_out = (_out < end ? 257 + (end - _out) : 257 - (_out - end)); + state.hold = hold; + state.bits = bits; + return; +}; diff --git a/blue_modules/pako/lib/zlib/inflate.js b/blue_modules/pako/lib/zlib/inflate.js new file mode 100644 index 00000000000..f5db4be048a --- /dev/null +++ b/blue_modules/pako/lib/zlib/inflate.js @@ -0,0 +1,1572 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +const adler32 = require('./adler32'); +const crc32 = require('./crc32'); +const inflate_fast = require('./inffast'); +const inflate_table = require('./inftrees'); + +const CODES = 0; +const LENS = 1; +const DISTS = 2; + +/* Public constants ==========================================================*/ +/* ===========================================================================*/ + +const { + Z_FINISH, Z_BLOCK, Z_TREES, + Z_OK, Z_STREAM_END, Z_NEED_DICT, Z_STREAM_ERROR, Z_DATA_ERROR, Z_MEM_ERROR, Z_BUF_ERROR, + Z_DEFLATED +} = require('./constants'); + + +/* STATES ====================================================================*/ +/* ===========================================================================*/ + + +const HEAD = 16180; /* i: waiting for magic header */ +const FLAGS = 16181; /* i: waiting for method and flags (gzip) */ +const TIME = 16182; /* i: waiting for modification time (gzip) */ +const OS = 16183; /* i: waiting for extra flags and operating system (gzip) */ +const EXLEN = 16184; /* i: waiting for extra length (gzip) */ +const EXTRA = 16185; /* i: waiting for extra bytes (gzip) */ +const NAME = 16186; /* i: waiting for end of file name (gzip) */ +const COMMENT = 16187; /* i: waiting for end of comment (gzip) */ +const HCRC = 16188; /* i: waiting for header crc (gzip) */ +const DICTID = 16189; /* i: waiting for dictionary check value */ +const DICT = 16190; /* waiting for inflateSetDictionary() call */ +const TYPE = 16191; /* i: waiting for type bits, including last-flag bit */ +const TYPEDO = 16192; /* i: same, but skip check to exit inflate on new block */ +const STORED = 16193; /* i: waiting for stored size (length and complement) */ +const COPY_ = 16194; /* i/o: same as COPY below, but only first time in */ +const COPY = 16195; /* i/o: waiting for input or output to copy stored block */ +const TABLE = 16196; /* i: waiting for dynamic block table lengths */ +const LENLENS = 16197; /* i: waiting for code length code lengths */ +const CODELENS = 16198; /* i: waiting for length/lit and distance code lengths */ +const LEN_ = 16199; /* i: same as LEN below, but only first time in */ +const LEN = 16200; /* i: waiting for length/lit/eob code */ +const LENEXT = 16201; /* i: waiting for length extra bits */ +const DIST = 16202; /* i: waiting for distance code */ +const DISTEXT = 16203; /* i: waiting for distance extra bits */ +const MATCH = 16204; /* o: waiting for output space to copy string */ +const LIT = 16205; /* o: waiting for output space to write literal */ +const CHECK = 16206; /* i: waiting for 32-bit check value */ +const LENGTH = 16207; /* i: waiting for 32-bit length (gzip) */ +const DONE = 16208; /* finished check, done -- remain here until reset */ +const BAD = 16209; /* got a data error -- remain here until reset */ +const MEM = 16210; /* got an inflate() memory error -- remain here until reset */ +const SYNC = 16211; /* looking for synchronization bytes to restart inflate() */ + +/* ===========================================================================*/ + + + +const ENOUGH_LENS = 852; +const ENOUGH_DISTS = 592; +//const ENOUGH = (ENOUGH_LENS+ENOUGH_DISTS); + +const MAX_WBITS = 15; +/* 32K LZ77 window */ +const DEF_WBITS = MAX_WBITS; + + +const zswap32 = (q) => { + + return (((q >>> 24) & 0xff) + + ((q >>> 8) & 0xff00) + + ((q & 0xff00) << 8) + + ((q & 0xff) << 24)); +}; + + +function InflateState() { + this.strm = null; /* pointer back to this zlib stream */ + this.mode = 0; /* current inflate mode */ + this.last = false; /* true if processing last block */ + this.wrap = 0; /* bit 0 true for zlib, bit 1 true for gzip, + bit 2 true to validate check value */ + this.havedict = false; /* true if dictionary provided */ + this.flags = 0; /* gzip header method and flags (0 if zlib), or + -1 if raw or no header yet */ + this.dmax = 0; /* zlib header max distance (INFLATE_STRICT) */ + this.check = 0; /* protected copy of check value */ + this.total = 0; /* protected copy of output count */ + // TODO: may be {} + this.head = null; /* where to save gzip header information */ + + /* sliding window */ + this.wbits = 0; /* log base 2 of requested window size */ + this.wsize = 0; /* window size or zero if not using window */ + this.whave = 0; /* valid bytes in the window */ + this.wnext = 0; /* window write index */ + this.window = null; /* allocated sliding window, if needed */ + + /* bit accumulator */ + this.hold = 0; /* input bit accumulator */ + this.bits = 0; /* number of bits in "in" */ + + /* for string and stored block copying */ + this.length = 0; /* literal or length of data to copy */ + this.offset = 0; /* distance back to copy string from */ + + /* for table and code decoding */ + this.extra = 0; /* extra bits needed */ + + /* fixed and dynamic code tables */ + this.lencode = null; /* starting table for length/literal codes */ + this.distcode = null; /* starting table for distance codes */ + this.lenbits = 0; /* index bits for lencode */ + this.distbits = 0; /* index bits for distcode */ + + /* dynamic table building */ + this.ncode = 0; /* number of code length code lengths */ + this.nlen = 0; /* number of length code lengths */ + this.ndist = 0; /* number of distance code lengths */ + this.have = 0; /* number of code lengths in lens[] */ + this.next = null; /* next available space in codes[] */ + + this.lens = new Uint16Array(320); /* temporary storage for code lengths */ + this.work = new Uint16Array(288); /* work area for code table building */ + + /* + because we don't have pointers in js, we use lencode and distcode directly + as buffers so we don't need codes + */ + //this.codes = new Int32Array(ENOUGH); /* space for code tables */ + this.lendyn = null; /* dynamic table for length/literal codes (JS specific) */ + this.distdyn = null; /* dynamic table for distance codes (JS specific) */ + this.sane = 0; /* if false, allow invalid distance too far */ + this.back = 0; /* bits back of last unprocessed length/lit */ + this.was = 0; /* initial length of match */ +} + + +const inflateStateCheck = (strm) => { + + if (!strm) { + return 1; + } + const state = strm.state; + if (!state || state.strm !== strm || + state.mode < HEAD || state.mode > SYNC) { + return 1; + } + return 0; +}; + + +const inflateResetKeep = (strm) => { + + if (inflateStateCheck(strm)) { return Z_STREAM_ERROR; } + const state = strm.state; + strm.total_in = strm.total_out = state.total = 0; + strm.msg = ''; /*Z_NULL*/ + if (state.wrap) { /* to support ill-conceived Java test suite */ + strm.adler = state.wrap & 1; + } + state.mode = HEAD; + state.last = 0; + state.havedict = 0; + state.flags = -1; + state.dmax = 32768; + state.head = null/*Z_NULL*/; + state.hold = 0; + state.bits = 0; + //state.lencode = state.distcode = state.next = state.codes; + state.lencode = state.lendyn = new Int32Array(ENOUGH_LENS); + state.distcode = state.distdyn = new Int32Array(ENOUGH_DISTS); + + state.sane = 1; + state.back = -1; + //Tracev((stderr, "inflate: reset\n")); + return Z_OK; +}; + + +const inflateReset = (strm) => { + + if (inflateStateCheck(strm)) { return Z_STREAM_ERROR; } + const state = strm.state; + state.wsize = 0; + state.whave = 0; + state.wnext = 0; + return inflateResetKeep(strm); + +}; + + +const inflateReset2 = (strm, windowBits) => { + let wrap; + + /* get the state */ + if (inflateStateCheck(strm)) { return Z_STREAM_ERROR; } + const state = strm.state; + + /* extract wrap request from windowBits parameter */ + if (windowBits < 0) { + wrap = 0; + windowBits = -windowBits; + } + else { + wrap = (windowBits >> 4) + 5; + if (windowBits < 48) { + windowBits &= 15; + } + } + + /* set number of window bits, free window if different */ + if (windowBits && (windowBits < 8 || windowBits > 15)) { + return Z_STREAM_ERROR; + } + if (state.window !== null && state.wbits !== windowBits) { + state.window = null; + } + + /* update state and reset the rest of it */ + state.wrap = wrap; + state.wbits = windowBits; + return inflateReset(strm); +}; + + +const inflateInit2 = (strm, windowBits) => { + + if (!strm) { return Z_STREAM_ERROR; } + //strm.msg = Z_NULL; /* in case we return an error */ + + const state = new InflateState(); + + //if (state === Z_NULL) return Z_MEM_ERROR; + //Tracev((stderr, "inflate: allocated\n")); + strm.state = state; + state.strm = strm; + state.window = null/*Z_NULL*/; + state.mode = HEAD; /* to pass state test in inflateReset2() */ + const ret = inflateReset2(strm, windowBits); + if (ret !== Z_OK) { + strm.state = null/*Z_NULL*/; + } + return ret; +}; + + +const inflateInit = (strm) => { + + return inflateInit2(strm, DEF_WBITS); +}; + + +/* + Return state with length and distance decoding tables and index sizes set to + fixed code decoding. Normally this returns fixed tables from inffixed.h. + If BUILDFIXED is defined, then instead this routine builds the tables the + first time it's called, and returns those tables the first time and + thereafter. This reduces the size of the code by about 2K bytes, in + exchange for a little execution time. However, BUILDFIXED should not be + used for threaded applications, since the rewriting of the tables and virgin + may not be thread-safe. + */ +let virgin = true; + +let lenfix, distfix; // We have no pointers in JS, so keep tables separate + + +const fixedtables = (state) => { + + /* build fixed huffman tables if first call (may not be thread safe) */ + if (virgin) { + lenfix = new Int32Array(512); + distfix = new Int32Array(32); + + /* literal/length table */ + let sym = 0; + while (sym < 144) { state.lens[sym++] = 8; } + while (sym < 256) { state.lens[sym++] = 9; } + while (sym < 280) { state.lens[sym++] = 7; } + while (sym < 288) { state.lens[sym++] = 8; } + + inflate_table(LENS, state.lens, 0, 288, lenfix, 0, state.work, { bits: 9 }); + + /* distance table */ + sym = 0; + while (sym < 32) { state.lens[sym++] = 5; } + + inflate_table(DISTS, state.lens, 0, 32, distfix, 0, state.work, { bits: 5 }); + + /* do this just once */ + virgin = false; + } + + state.lencode = lenfix; + state.lenbits = 9; + state.distcode = distfix; + state.distbits = 5; +}; + + +/* + Update the window with the last wsize (normally 32K) bytes written before + returning. If window does not exist yet, create it. This is only called + when a window is already in use, or when output has been written during this + inflate call, but the end of the deflate stream has not been reached yet. + It is also called to create a window for dictionary data when a dictionary + is loaded. + + Providing output buffers larger than 32K to inflate() should provide a speed + advantage, since only the last 32K of output is copied to the sliding window + upon return from inflate(), and since all distances after the first 32K of + output will fall in the output data, making match copies simpler and faster. + The advantage may be dependent on the size of the processor's data caches. + */ +const updatewindow = (strm, src, end, copy) => { + + let dist; + const state = strm.state; + + /* if it hasn't been done already, allocate space for the window */ + if (state.window === null) { + state.wsize = 1 << state.wbits; + state.wnext = 0; + state.whave = 0; + + state.window = new Uint8Array(state.wsize); + } + + /* copy state->wsize or less output bytes into the circular window */ + if (copy >= state.wsize) { + state.window.set(src.subarray(end - state.wsize, end), 0); + state.wnext = 0; + state.whave = state.wsize; + } + else { + dist = state.wsize - state.wnext; + if (dist > copy) { + dist = copy; + } + //zmemcpy(state->window + state->wnext, end - copy, dist); + state.window.set(src.subarray(end - copy, end - copy + dist), state.wnext); + copy -= dist; + if (copy) { + //zmemcpy(state->window, end - copy, copy); + state.window.set(src.subarray(end - copy, end), 0); + state.wnext = copy; + state.whave = state.wsize; + } + else { + state.wnext += dist; + if (state.wnext === state.wsize) { state.wnext = 0; } + if (state.whave < state.wsize) { state.whave += dist; } + } + } + return 0; +}; + + +const inflate = (strm, flush) => { + + let state; + let input, output; // input/output buffers + let next; /* next input INDEX */ + let put; /* next output INDEX */ + let have, left; /* available input and output */ + let hold; /* bit buffer */ + let bits; /* bits in bit buffer */ + let _in, _out; /* save starting available input and output */ + let copy; /* number of stored or match bytes to copy */ + let from; /* where to copy match bytes from */ + let from_source; + let here = 0; /* current decoding table entry */ + let here_bits, here_op, here_val; // paked "here" denormalized (JS specific) + //let last; /* parent table entry */ + let last_bits, last_op, last_val; // paked "last" denormalized (JS specific) + let len; /* length to copy for repeats, bits to drop */ + let ret; /* return code */ + const hbuf = new Uint8Array(4); /* buffer for gzip header crc calculation */ + let opts; + + let n; // temporary variable for NEED_BITS + + const order = /* permutation of code lengths */ + new Uint8Array([ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 ]); + + + if (inflateStateCheck(strm) || !strm.output || + (!strm.input && strm.avail_in !== 0)) { + return Z_STREAM_ERROR; + } + + state = strm.state; + if (state.mode === TYPE) { state.mode = TYPEDO; } /* skip check */ + + + //--- LOAD() --- + put = strm.next_out; + output = strm.output; + left = strm.avail_out; + next = strm.next_in; + input = strm.input; + have = strm.avail_in; + hold = state.hold; + bits = state.bits; + //--- + + _in = have; + _out = left; + ret = Z_OK; + + inf_leave: // goto emulation + for (;;) { + switch (state.mode) { + case HEAD: + if (state.wrap === 0) { + state.mode = TYPEDO; + break; + } + //=== NEEDBITS(16); + while (bits < 16) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if ((state.wrap & 2) && hold === 0x8b1f) { /* gzip header */ + if (state.wbits === 0) { + state.wbits = 15; + } + state.check = 0/*crc32(0L, Z_NULL, 0)*/; + //=== CRC2(state.check, hold); + hbuf[0] = hold & 0xff; + hbuf[1] = (hold >>> 8) & 0xff; + state.check = crc32(state.check, hbuf, 2, 0); + //===// + + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = FLAGS; + break; + } + if (state.head) { + state.head.done = false; + } + if (!(state.wrap & 1) || /* check if zlib header allowed */ + (((hold & 0xff)/*BITS(8)*/ << 8) + (hold >> 8)) % 31) { + strm.msg = 'incorrect header check'; + state.mode = BAD; + break; + } + if ((hold & 0x0f)/*BITS(4)*/ !== Z_DEFLATED) { + strm.msg = 'unknown compression method'; + state.mode = BAD; + break; + } + //--- DROPBITS(4) ---// + hold >>>= 4; + bits -= 4; + //---// + len = (hold & 0x0f)/*BITS(4)*/ + 8; + if (state.wbits === 0) { + state.wbits = len; + } + if (len > 15 || len > state.wbits) { + strm.msg = 'invalid window size'; + state.mode = BAD; + break; + } + + // !!! pako patch. Force use `options.windowBits` if passed. + // Required to always use max window size by default. + state.dmax = 1 << state.wbits; + //state.dmax = 1 << len; + + state.flags = 0; /* indicate zlib header */ + //Tracev((stderr, "inflate: zlib header ok\n")); + strm.adler = state.check = 1/*adler32(0L, Z_NULL, 0)*/; + state.mode = hold & 0x200 ? DICTID : TYPE; + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + break; + case FLAGS: + //=== NEEDBITS(16); */ + while (bits < 16) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.flags = hold; + if ((state.flags & 0xff) !== Z_DEFLATED) { + strm.msg = 'unknown compression method'; + state.mode = BAD; + break; + } + if (state.flags & 0xe000) { + strm.msg = 'unknown header flags set'; + state.mode = BAD; + break; + } + if (state.head) { + state.head.text = ((hold >> 8) & 1); + } + if ((state.flags & 0x0200) && (state.wrap & 4)) { + //=== CRC2(state.check, hold); + hbuf[0] = hold & 0xff; + hbuf[1] = (hold >>> 8) & 0xff; + state.check = crc32(state.check, hbuf, 2, 0); + //===// + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = TIME; + /* falls through */ + case TIME: + //=== NEEDBITS(32); */ + while (bits < 32) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if (state.head) { + state.head.time = hold; + } + if ((state.flags & 0x0200) && (state.wrap & 4)) { + //=== CRC4(state.check, hold) + hbuf[0] = hold & 0xff; + hbuf[1] = (hold >>> 8) & 0xff; + hbuf[2] = (hold >>> 16) & 0xff; + hbuf[3] = (hold >>> 24) & 0xff; + state.check = crc32(state.check, hbuf, 4, 0); + //=== + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = OS; + /* falls through */ + case OS: + //=== NEEDBITS(16); */ + while (bits < 16) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if (state.head) { + state.head.xflags = (hold & 0xff); + state.head.os = (hold >> 8); + } + if ((state.flags & 0x0200) && (state.wrap & 4)) { + //=== CRC2(state.check, hold); + hbuf[0] = hold & 0xff; + hbuf[1] = (hold >>> 8) & 0xff; + state.check = crc32(state.check, hbuf, 2, 0); + //===// + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = EXLEN; + /* falls through */ + case EXLEN: + if (state.flags & 0x0400) { + //=== NEEDBITS(16); */ + while (bits < 16) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.length = hold; + if (state.head) { + state.head.extra_len = hold; + } + if ((state.flags & 0x0200) && (state.wrap & 4)) { + //=== CRC2(state.check, hold); + hbuf[0] = hold & 0xff; + hbuf[1] = (hold >>> 8) & 0xff; + state.check = crc32(state.check, hbuf, 2, 0); + //===// + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + } + else if (state.head) { + state.head.extra = null/*Z_NULL*/; + } + state.mode = EXTRA; + /* falls through */ + case EXTRA: + if (state.flags & 0x0400) { + copy = state.length; + if (copy > have) { copy = have; } + if (copy) { + if (state.head) { + len = state.head.extra_len - state.length; + if (!state.head.extra) { + // Use untyped array for more convenient processing later + state.head.extra = new Uint8Array(state.head.extra_len); + } + state.head.extra.set( + input.subarray( + next, + // extra field is limited to 65536 bytes + // - no need for additional size check + next + copy + ), + /*len + copy > state.head.extra_max - len ? state.head.extra_max : copy,*/ + len + ); + //zmemcpy(state.head.extra + len, next, + // len + copy > state.head.extra_max ? + // state.head.extra_max - len : copy); + } + if ((state.flags & 0x0200) && (state.wrap & 4)) { + state.check = crc32(state.check, input, copy, next); + } + have -= copy; + next += copy; + state.length -= copy; + } + if (state.length) { break inf_leave; } + } + state.length = 0; + state.mode = NAME; + /* falls through */ + case NAME: + if (state.flags & 0x0800) { + if (have === 0) { break inf_leave; } + copy = 0; + do { + // TODO: 2 or 1 bytes? + len = input[next + copy++]; + /* use constant limit because in js we should not preallocate memory */ + if (state.head && len && + (state.length < 65536 /*state.head.name_max*/)) { + state.head.name += String.fromCharCode(len); + } + } while (len && copy < have); + + if ((state.flags & 0x0200) && (state.wrap & 4)) { + state.check = crc32(state.check, input, copy, next); + } + have -= copy; + next += copy; + if (len) { break inf_leave; } + } + else if (state.head) { + state.head.name = null; + } + state.length = 0; + state.mode = COMMENT; + /* falls through */ + case COMMENT: + if (state.flags & 0x1000) { + if (have === 0) { break inf_leave; } + copy = 0; + do { + len = input[next + copy++]; + /* use constant limit because in js we should not preallocate memory */ + if (state.head && len && + (state.length < 65536 /*state.head.comm_max*/)) { + state.head.comment += String.fromCharCode(len); + } + } while (len && copy < have); + if ((state.flags & 0x0200) && (state.wrap & 4)) { + state.check = crc32(state.check, input, copy, next); + } + have -= copy; + next += copy; + if (len) { break inf_leave; } + } + else if (state.head) { + state.head.comment = null; + } + state.mode = HCRC; + /* falls through */ + case HCRC: + if (state.flags & 0x0200) { + //=== NEEDBITS(16); */ + while (bits < 16) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if ((state.wrap & 4) && hold !== (state.check & 0xffff)) { + strm.msg = 'header crc mismatch'; + state.mode = BAD; + break; + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + } + if (state.head) { + state.head.hcrc = ((state.flags >> 9) & 1); + state.head.done = true; + } + strm.adler = state.check = 0; + state.mode = TYPE; + break; + case DICTID: + //=== NEEDBITS(32); */ + while (bits < 32) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + strm.adler = state.check = zswap32(hold); + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = DICT; + /* falls through */ + case DICT: + if (state.havedict === 0) { + //--- RESTORE() --- + strm.next_out = put; + strm.avail_out = left; + strm.next_in = next; + strm.avail_in = have; + state.hold = hold; + state.bits = bits; + //--- + return Z_NEED_DICT; + } + strm.adler = state.check = 1/*adler32(0L, Z_NULL, 0)*/; + state.mode = TYPE; + /* falls through */ + case TYPE: + if (flush === Z_BLOCK || flush === Z_TREES) { break inf_leave; } + /* falls through */ + case TYPEDO: + if (state.last) { + //--- BYTEBITS() ---// + hold >>>= bits & 7; + bits -= bits & 7; + //---// + state.mode = CHECK; + break; + } + //=== NEEDBITS(3); */ + while (bits < 3) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.last = (hold & 0x01)/*BITS(1)*/; + //--- DROPBITS(1) ---// + hold >>>= 1; + bits -= 1; + //---// + + switch ((hold & 0x03)/*BITS(2)*/) { + case 0: /* stored block */ + //Tracev((stderr, "inflate: stored block%s\n", + // state.last ? " (last)" : "")); + state.mode = STORED; + break; + case 1: /* fixed block */ + fixedtables(state); + //Tracev((stderr, "inflate: fixed codes block%s\n", + // state.last ? " (last)" : "")); + state.mode = LEN_; /* decode codes */ + if (flush === Z_TREES) { + //--- DROPBITS(2) ---// + hold >>>= 2; + bits -= 2; + //---// + break inf_leave; + } + break; + case 2: /* dynamic block */ + //Tracev((stderr, "inflate: dynamic codes block%s\n", + // state.last ? " (last)" : "")); + state.mode = TABLE; + break; + case 3: + strm.msg = 'invalid block type'; + state.mode = BAD; + } + //--- DROPBITS(2) ---// + hold >>>= 2; + bits -= 2; + //---// + break; + case STORED: + //--- BYTEBITS() ---// /* go to byte boundary */ + hold >>>= bits & 7; + bits -= bits & 7; + //---// + //=== NEEDBITS(32); */ + while (bits < 32) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if ((hold & 0xffff) !== ((hold >>> 16) ^ 0xffff)) { + strm.msg = 'invalid stored block lengths'; + state.mode = BAD; + break; + } + state.length = hold & 0xffff; + //Tracev((stderr, "inflate: stored length %u\n", + // state.length)); + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + state.mode = COPY_; + if (flush === Z_TREES) { break inf_leave; } + /* falls through */ + case COPY_: + state.mode = COPY; + /* falls through */ + case COPY: + copy = state.length; + if (copy) { + if (copy > have) { copy = have; } + if (copy > left) { copy = left; } + if (copy === 0) { break inf_leave; } + //--- zmemcpy(put, next, copy); --- + output.set(input.subarray(next, next + copy), put); + //---// + have -= copy; + next += copy; + left -= copy; + put += copy; + state.length -= copy; + break; + } + //Tracev((stderr, "inflate: stored end\n")); + state.mode = TYPE; + break; + case TABLE: + //=== NEEDBITS(14); */ + while (bits < 14) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.nlen = (hold & 0x1f)/*BITS(5)*/ + 257; + //--- DROPBITS(5) ---// + hold >>>= 5; + bits -= 5; + //---// + state.ndist = (hold & 0x1f)/*BITS(5)*/ + 1; + //--- DROPBITS(5) ---// + hold >>>= 5; + bits -= 5; + //---// + state.ncode = (hold & 0x0f)/*BITS(4)*/ + 4; + //--- DROPBITS(4) ---// + hold >>>= 4; + bits -= 4; + //---// +//#ifndef PKZIP_BUG_WORKAROUND + if (state.nlen > 286 || state.ndist > 30) { + strm.msg = 'too many length or distance symbols'; + state.mode = BAD; + break; + } +//#endif + //Tracev((stderr, "inflate: table sizes ok\n")); + state.have = 0; + state.mode = LENLENS; + /* falls through */ + case LENLENS: + while (state.have < state.ncode) { + //=== NEEDBITS(3); + while (bits < 3) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.lens[order[state.have++]] = (hold & 0x07);//BITS(3); + //--- DROPBITS(3) ---// + hold >>>= 3; + bits -= 3; + //---// + } + while (state.have < 19) { + state.lens[order[state.have++]] = 0; + } + // We have separate tables & no pointers. 2 commented lines below not needed. + //state.next = state.codes; + //state.lencode = state.next; + // Switch to use dynamic table + state.lencode = state.lendyn; + state.lenbits = 7; + + opts = { bits: state.lenbits }; + ret = inflate_table(CODES, state.lens, 0, 19, state.lencode, 0, state.work, opts); + state.lenbits = opts.bits; + + if (ret) { + strm.msg = 'invalid code lengths set'; + state.mode = BAD; + break; + } + //Tracev((stderr, "inflate: code lengths ok\n")); + state.have = 0; + state.mode = CODELENS; + /* falls through */ + case CODELENS: + while (state.have < state.nlen + state.ndist) { + for (;;) { + here = state.lencode[hold & ((1 << state.lenbits) - 1)];/*BITS(state.lenbits)*/ + here_bits = here >>> 24; + here_op = (here >>> 16) & 0xff; + here_val = here & 0xffff; + + if ((here_bits) <= bits) { break; } + //--- PULLBYTE() ---// + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + //---// + } + if (here_val < 16) { + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + state.lens[state.have++] = here_val; + } + else { + if (here_val === 16) { + //=== NEEDBITS(here.bits + 2); + n = here_bits + 2; + while (bits < n) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + if (state.have === 0) { + strm.msg = 'invalid bit length repeat'; + state.mode = BAD; + break; + } + len = state.lens[state.have - 1]; + copy = 3 + (hold & 0x03);//BITS(2); + //--- DROPBITS(2) ---// + hold >>>= 2; + bits -= 2; + //---// + } + else if (here_val === 17) { + //=== NEEDBITS(here.bits + 3); + n = here_bits + 3; + while (bits < n) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + len = 0; + copy = 3 + (hold & 0x07);//BITS(3); + //--- DROPBITS(3) ---// + hold >>>= 3; + bits -= 3; + //---// + } + else { + //=== NEEDBITS(here.bits + 7); + n = here_bits + 7; + while (bits < n) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + len = 0; + copy = 11 + (hold & 0x7f);//BITS(7); + //--- DROPBITS(7) ---// + hold >>>= 7; + bits -= 7; + //---// + } + if (state.have + copy > state.nlen + state.ndist) { + strm.msg = 'invalid bit length repeat'; + state.mode = BAD; + break; + } + while (copy--) { + state.lens[state.have++] = len; + } + } + } + + /* handle error breaks in while */ + if (state.mode === BAD) { break; } + + /* check for end-of-block code (better have one) */ + if (state.lens[256] === 0) { + strm.msg = 'invalid code -- missing end-of-block'; + state.mode = BAD; + break; + } + + /* build code tables -- note: do not change the lenbits or distbits + values here (9 and 6) without reading the comments in inftrees.h + concerning the ENOUGH constants, which depend on those values */ + state.lenbits = 9; + + opts = { bits: state.lenbits }; + ret = inflate_table(LENS, state.lens, 0, state.nlen, state.lencode, 0, state.work, opts); + // We have separate tables & no pointers. 2 commented lines below not needed. + // state.next_index = opts.table_index; + state.lenbits = opts.bits; + // state.lencode = state.next; + + if (ret) { + strm.msg = 'invalid literal/lengths set'; + state.mode = BAD; + break; + } + + state.distbits = 6; + //state.distcode.copy(state.codes); + // Switch to use dynamic table + state.distcode = state.distdyn; + opts = { bits: state.distbits }; + ret = inflate_table(DISTS, state.lens, state.nlen, state.ndist, state.distcode, 0, state.work, opts); + // We have separate tables & no pointers. 2 commented lines below not needed. + // state.next_index = opts.table_index; + state.distbits = opts.bits; + // state.distcode = state.next; + + if (ret) { + strm.msg = 'invalid distances set'; + state.mode = BAD; + break; + } + //Tracev((stderr, 'inflate: codes ok\n')); + state.mode = LEN_; + if (flush === Z_TREES) { break inf_leave; } + /* falls through */ + case LEN_: + state.mode = LEN; + /* falls through */ + case LEN: + if (have >= 6 && left >= 258) { + //--- RESTORE() --- + strm.next_out = put; + strm.avail_out = left; + strm.next_in = next; + strm.avail_in = have; + state.hold = hold; + state.bits = bits; + //--- + inflate_fast(strm, _out); + //--- LOAD() --- + put = strm.next_out; + output = strm.output; + left = strm.avail_out; + next = strm.next_in; + input = strm.input; + have = strm.avail_in; + hold = state.hold; + bits = state.bits; + //--- + + if (state.mode === TYPE) { + state.back = -1; + } + break; + } + state.back = 0; + for (;;) { + here = state.lencode[hold & ((1 << state.lenbits) - 1)]; /*BITS(state.lenbits)*/ + here_bits = here >>> 24; + here_op = (here >>> 16) & 0xff; + here_val = here & 0xffff; + + if (here_bits <= bits) { break; } + //--- PULLBYTE() ---// + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + //---// + } + if (here_op && (here_op & 0xf0) === 0) { + last_bits = here_bits; + last_op = here_op; + last_val = here_val; + for (;;) { + here = state.lencode[last_val + + ((hold & ((1 << (last_bits + last_op)) - 1))/*BITS(last.bits + last.op)*/ >> last_bits)]; + here_bits = here >>> 24; + here_op = (here >>> 16) & 0xff; + here_val = here & 0xffff; + + if ((last_bits + here_bits) <= bits) { break; } + //--- PULLBYTE() ---// + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + //---// + } + //--- DROPBITS(last.bits) ---// + hold >>>= last_bits; + bits -= last_bits; + //---// + state.back += last_bits; + } + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + state.back += here_bits; + state.length = here_val; + if (here_op === 0) { + //Tracevv((stderr, here.val >= 0x20 && here.val < 0x7f ? + // "inflate: literal '%c'\n" : + // "inflate: literal 0x%02x\n", here.val)); + state.mode = LIT; + break; + } + if (here_op & 32) { + //Tracevv((stderr, "inflate: end of block\n")); + state.back = -1; + state.mode = TYPE; + break; + } + if (here_op & 64) { + strm.msg = 'invalid literal/length code'; + state.mode = BAD; + break; + } + state.extra = here_op & 15; + state.mode = LENEXT; + /* falls through */ + case LENEXT: + if (state.extra) { + //=== NEEDBITS(state.extra); + n = state.extra; + while (bits < n) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.length += hold & ((1 << state.extra) - 1)/*BITS(state.extra)*/; + //--- DROPBITS(state.extra) ---// + hold >>>= state.extra; + bits -= state.extra; + //---// + state.back += state.extra; + } + //Tracevv((stderr, "inflate: length %u\n", state.length)); + state.was = state.length; + state.mode = DIST; + /* falls through */ + case DIST: + for (;;) { + here = state.distcode[hold & ((1 << state.distbits) - 1)];/*BITS(state.distbits)*/ + here_bits = here >>> 24; + here_op = (here >>> 16) & 0xff; + here_val = here & 0xffff; + + if ((here_bits) <= bits) { break; } + //--- PULLBYTE() ---// + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + //---// + } + if ((here_op & 0xf0) === 0) { + last_bits = here_bits; + last_op = here_op; + last_val = here_val; + for (;;) { + here = state.distcode[last_val + + ((hold & ((1 << (last_bits + last_op)) - 1))/*BITS(last.bits + last.op)*/ >> last_bits)]; + here_bits = here >>> 24; + here_op = (here >>> 16) & 0xff; + here_val = here & 0xffff; + + if ((last_bits + here_bits) <= bits) { break; } + //--- PULLBYTE() ---// + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + //---// + } + //--- DROPBITS(last.bits) ---// + hold >>>= last_bits; + bits -= last_bits; + //---// + state.back += last_bits; + } + //--- DROPBITS(here.bits) ---// + hold >>>= here_bits; + bits -= here_bits; + //---// + state.back += here_bits; + if (here_op & 64) { + strm.msg = 'invalid distance code'; + state.mode = BAD; + break; + } + state.offset = here_val; + state.extra = (here_op) & 15; + state.mode = DISTEXT; + /* falls through */ + case DISTEXT: + if (state.extra) { + //=== NEEDBITS(state.extra); + n = state.extra; + while (bits < n) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + state.offset += hold & ((1 << state.extra) - 1)/*BITS(state.extra)*/; + //--- DROPBITS(state.extra) ---// + hold >>>= state.extra; + bits -= state.extra; + //---// + state.back += state.extra; + } +//#ifdef INFLATE_STRICT + if (state.offset > state.dmax) { + strm.msg = 'invalid distance too far back'; + state.mode = BAD; + break; + } +//#endif + //Tracevv((stderr, "inflate: distance %u\n", state.offset)); + state.mode = MATCH; + /* falls through */ + case MATCH: + if (left === 0) { break inf_leave; } + copy = _out - left; + if (state.offset > copy) { /* copy from window */ + copy = state.offset - copy; + if (copy > state.whave) { + if (state.sane) { + strm.msg = 'invalid distance too far back'; + state.mode = BAD; + break; + } +// (!) This block is disabled in zlib defaults, +// don't enable it for binary compatibility +//#ifdef INFLATE_ALLOW_INVALID_DISTANCE_TOOFAR_ARRR +// Trace((stderr, "inflate.c too far\n")); +// copy -= state.whave; +// if (copy > state.length) { copy = state.length; } +// if (copy > left) { copy = left; } +// left -= copy; +// state.length -= copy; +// do { +// output[put++] = 0; +// } while (--copy); +// if (state.length === 0) { state.mode = LEN; } +// break; +//#endif + } + if (copy > state.wnext) { + copy -= state.wnext; + from = state.wsize - copy; + } + else { + from = state.wnext - copy; + } + if (copy > state.length) { copy = state.length; } + from_source = state.window; + } + else { /* copy from output */ + from_source = output; + from = put - state.offset; + copy = state.length; + } + if (copy > left) { copy = left; } + left -= copy; + state.length -= copy; + do { + output[put++] = from_source[from++]; + } while (--copy); + if (state.length === 0) { state.mode = LEN; } + break; + case LIT: + if (left === 0) { break inf_leave; } + output[put++] = state.length; + left--; + state.mode = LEN; + break; + case CHECK: + if (state.wrap) { + //=== NEEDBITS(32); + while (bits < 32) { + if (have === 0) { break inf_leave; } + have--; + // Use '|' instead of '+' to make sure that result is signed + hold |= input[next++] << bits; + bits += 8; + } + //===// + _out -= left; + strm.total_out += _out; + state.total += _out; + if ((state.wrap & 4) && _out) { + strm.adler = state.check = + /*UPDATE_CHECK(state.check, put - _out, _out);*/ + (state.flags ? crc32(state.check, output, _out, put - _out) : adler32(state.check, output, _out, put - _out)); + + } + _out = left; + // NB: crc32 stored as signed 32-bit int, zswap32 returns signed too + if ((state.wrap & 4) && (state.flags ? hold : zswap32(hold)) !== state.check) { + strm.msg = 'incorrect data check'; + state.mode = BAD; + break; + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + //Tracev((stderr, "inflate: check matches trailer\n")); + } + state.mode = LENGTH; + /* falls through */ + case LENGTH: + if (state.wrap && state.flags) { + //=== NEEDBITS(32); + while (bits < 32) { + if (have === 0) { break inf_leave; } + have--; + hold += input[next++] << bits; + bits += 8; + } + //===// + if ((state.wrap & 4) && hold !== (state.total & 0xffffffff)) { + strm.msg = 'incorrect length check'; + state.mode = BAD; + break; + } + //=== INITBITS(); + hold = 0; + bits = 0; + //===// + //Tracev((stderr, "inflate: length matches trailer\n")); + } + state.mode = DONE; + /* falls through */ + case DONE: + ret = Z_STREAM_END; + break inf_leave; + case BAD: + ret = Z_DATA_ERROR; + break inf_leave; + case MEM: + return Z_MEM_ERROR; + case SYNC: + /* falls through */ + default: + return Z_STREAM_ERROR; + } + } + + // inf_leave <- here is real place for "goto inf_leave", emulated via "break inf_leave" + + /* + Return from inflate(), updating the total counts and the check value. + If there was no progress during the inflate() call, return a buffer + error. Call updatewindow() to create and/or update the window state. + Note: a memory error from inflate() is non-recoverable. + */ + + //--- RESTORE() --- + strm.next_out = put; + strm.avail_out = left; + strm.next_in = next; + strm.avail_in = have; + state.hold = hold; + state.bits = bits; + //--- + + if (state.wsize || (_out !== strm.avail_out && state.mode < BAD && + (state.mode < CHECK || flush !== Z_FINISH))) { + if (updatewindow(strm, strm.output, strm.next_out, _out - strm.avail_out)) { + state.mode = MEM; + return Z_MEM_ERROR; + } + } + _in -= strm.avail_in; + _out -= strm.avail_out; + strm.total_in += _in; + strm.total_out += _out; + state.total += _out; + if ((state.wrap & 4) && _out) { + strm.adler = state.check = /*UPDATE_CHECK(state.check, strm.next_out - _out, _out);*/ + (state.flags ? crc32(state.check, output, _out, strm.next_out - _out) : adler32(state.check, output, _out, strm.next_out - _out)); + } + strm.data_type = state.bits + (state.last ? 64 : 0) + + (state.mode === TYPE ? 128 : 0) + + (state.mode === LEN_ || state.mode === COPY_ ? 256 : 0); + if (((_in === 0 && _out === 0) || flush === Z_FINISH) && ret === Z_OK) { + ret = Z_BUF_ERROR; + } + return ret; +}; + + +const inflateEnd = (strm) => { + + if (inflateStateCheck(strm)) { + return Z_STREAM_ERROR; + } + + let state = strm.state; + if (state.window) { + state.window = null; + } + strm.state = null; + return Z_OK; +}; + + +const inflateGetHeader = (strm, head) => { + + /* check state */ + if (inflateStateCheck(strm)) { return Z_STREAM_ERROR; } + const state = strm.state; + if ((state.wrap & 2) === 0) { return Z_STREAM_ERROR; } + + /* save header structure */ + state.head = head; + head.done = false; + return Z_OK; +}; + + +const inflateSetDictionary = (strm, dictionary) => { + const dictLength = dictionary.length; + + let state; + let dictid; + let ret; + + /* check state */ + if (inflateStateCheck(strm)) { return Z_STREAM_ERROR; } + state = strm.state; + + if (state.wrap !== 0 && state.mode !== DICT) { + return Z_STREAM_ERROR; + } + + /* check for correct dictionary identifier */ + if (state.mode === DICT) { + dictid = 1; /* adler32(0, null, 0)*/ + /* dictid = adler32(dictid, dictionary, dictLength); */ + dictid = adler32(dictid, dictionary, dictLength, 0); + if (dictid !== state.check) { + return Z_DATA_ERROR; + } + } + /* copy dictionary to window using updatewindow(), which will amend the + existing dictionary if appropriate */ + ret = updatewindow(strm, dictionary, dictLength, dictLength); + if (ret) { + state.mode = MEM; + return Z_MEM_ERROR; + } + state.havedict = 1; + // Tracev((stderr, "inflate: dictionary set\n")); + return Z_OK; +}; + + +module.exports.inflateReset = inflateReset; +module.exports.inflateReset2 = inflateReset2; +module.exports.inflateResetKeep = inflateResetKeep; +module.exports.inflateInit = inflateInit; +module.exports.inflateInit2 = inflateInit2; +module.exports.inflate = inflate; +module.exports.inflateEnd = inflateEnd; +module.exports.inflateGetHeader = inflateGetHeader; +module.exports.inflateSetDictionary = inflateSetDictionary; +module.exports.inflateInfo = 'pako inflate (from Nodeca project)'; + +/* Not implemented +module.exports.inflateCodesUsed = inflateCodesUsed; +module.exports.inflateCopy = inflateCopy; +module.exports.inflateGetDictionary = inflateGetDictionary; +module.exports.inflateMark = inflateMark; +module.exports.inflatePrime = inflatePrime; +module.exports.inflateSync = inflateSync; +module.exports.inflateSyncPoint = inflateSyncPoint; +module.exports.inflateUndermine = inflateUndermine; +module.exports.inflateValidate = inflateValidate; +*/ diff --git a/blue_modules/pako/lib/zlib/inftrees.js b/blue_modules/pako/lib/zlib/inftrees.js new file mode 100644 index 00000000000..eee389eacce --- /dev/null +++ b/blue_modules/pako/lib/zlib/inftrees.js @@ -0,0 +1,340 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +const MAXBITS = 15; +const ENOUGH_LENS = 852; +const ENOUGH_DISTS = 592; +//const ENOUGH = (ENOUGH_LENS+ENOUGH_DISTS); + +const CODES = 0; +const LENS = 1; +const DISTS = 2; + +const lbase = new Uint16Array([ /* Length codes 257..285 base */ + 3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, + 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258, 0, 0 +]); + +const lext = new Uint8Array([ /* Length codes 257..285 extra */ + 16, 16, 16, 16, 16, 16, 16, 16, 17, 17, 17, 17, 18, 18, 18, 18, + 19, 19, 19, 19, 20, 20, 20, 20, 21, 21, 21, 21, 16, 72, 78 +]); + +const dbase = new Uint16Array([ /* Distance codes 0..29 base */ + 1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, + 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, + 8193, 12289, 16385, 24577, 0, 0 +]); + +const dext = new Uint8Array([ /* Distance codes 0..29 extra */ + 16, 16, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, + 23, 23, 24, 24, 25, 25, 26, 26, 27, 27, + 28, 28, 29, 29, 64, 64 +]); + +const inflate_table = (type, lens, lens_index, codes, table, table_index, work, opts) => +{ + const bits = opts.bits; + //here = opts.here; /* table entry for duplication */ + + let len = 0; /* a code's length in bits */ + let sym = 0; /* index of code symbols */ + let min = 0, max = 0; /* minimum and maximum code lengths */ + let root = 0; /* number of index bits for root table */ + let curr = 0; /* number of index bits for current table */ + let drop = 0; /* code bits to drop for sub-table */ + let left = 0; /* number of prefix codes available */ + let used = 0; /* code entries in table used */ + let huff = 0; /* Huffman code */ + let incr; /* for incrementing code, index */ + let fill; /* index for replicating entries */ + let low; /* low bits for current root entry */ + let mask; /* mask for low root bits */ + let next; /* next available space in table */ + let base = null; /* base value table to use */ +// let shoextra; /* extra bits table to use */ + let match; /* use base and extra for symbol >= match */ + const count = new Uint16Array(MAXBITS + 1); //[MAXBITS+1]; /* number of codes of each length */ + const offs = new Uint16Array(MAXBITS + 1); //[MAXBITS+1]; /* offsets in table for each length */ + let extra = null; + + let here_bits, here_op, here_val; + + /* + Process a set of code lengths to create a canonical Huffman code. The + code lengths are lens[0..codes-1]. Each length corresponds to the + symbols 0..codes-1. The Huffman code is generated by first sorting the + symbols by length from short to long, and retaining the symbol order + for codes with equal lengths. Then the code starts with all zero bits + for the first code of the shortest length, and the codes are integer + increments for the same length, and zeros are appended as the length + increases. For the deflate format, these bits are stored backwards + from their more natural integer increment ordering, and so when the + decoding tables are built in the large loop below, the integer codes + are incremented backwards. + + This routine assumes, but does not check, that all of the entries in + lens[] are in the range 0..MAXBITS. The caller must assure this. + 1..MAXBITS is interpreted as that code length. zero means that that + symbol does not occur in this code. + + The codes are sorted by computing a count of codes for each length, + creating from that a table of starting indices for each length in the + sorted table, and then entering the symbols in order in the sorted + table. The sorted table is work[], with that space being provided by + the caller. + + The length counts are used for other purposes as well, i.e. finding + the minimum and maximum length codes, determining if there are any + codes at all, checking for a valid set of lengths, and looking ahead + at length counts to determine sub-table sizes when building the + decoding tables. + */ + + /* accumulate lengths for codes (assumes lens[] all in 0..MAXBITS) */ + for (len = 0; len <= MAXBITS; len++) { + count[len] = 0; + } + for (sym = 0; sym < codes; sym++) { + count[lens[lens_index + sym]]++; + } + + /* bound code lengths, force root to be within code lengths */ + root = bits; + for (max = MAXBITS; max >= 1; max--) { + if (count[max] !== 0) { break; } + } + if (root > max) { + root = max; + } + if (max === 0) { /* no symbols to code at all */ + //table.op[opts.table_index] = 64; //here.op = (var char)64; /* invalid code marker */ + //table.bits[opts.table_index] = 1; //here.bits = (var char)1; + //table.val[opts.table_index++] = 0; //here.val = (var short)0; + table[table_index++] = (1 << 24) | (64 << 16) | 0; + + + //table.op[opts.table_index] = 64; + //table.bits[opts.table_index] = 1; + //table.val[opts.table_index++] = 0; + table[table_index++] = (1 << 24) | (64 << 16) | 0; + + opts.bits = 1; + return 0; /* no symbols, but wait for decoding to report error */ + } + for (min = 1; min < max; min++) { + if (count[min] !== 0) { break; } + } + if (root < min) { + root = min; + } + + /* check for an over-subscribed or incomplete set of lengths */ + left = 1; + for (len = 1; len <= MAXBITS; len++) { + left <<= 1; + left -= count[len]; + if (left < 0) { + return -1; + } /* over-subscribed */ + } + if (left > 0 && (type === CODES || max !== 1)) { + return -1; /* incomplete set */ + } + + /* generate offsets into symbol table for each length for sorting */ + offs[1] = 0; + for (len = 1; len < MAXBITS; len++) { + offs[len + 1] = offs[len] + count[len]; + } + + /* sort symbols by length, by symbol order within each length */ + for (sym = 0; sym < codes; sym++) { + if (lens[lens_index + sym] !== 0) { + work[offs[lens[lens_index + sym]]++] = sym; + } + } + + /* + Create and fill in decoding tables. In this loop, the table being + filled is at next and has curr index bits. The code being used is huff + with length len. That code is converted to an index by dropping drop + bits off of the bottom. For codes where len is less than drop + curr, + those top drop + curr - len bits are incremented through all values to + fill the table with replicated entries. + + root is the number of index bits for the root table. When len exceeds + root, sub-tables are created pointed to by the root entry with an index + of the low root bits of huff. This is saved in low to check for when a + new sub-table should be started. drop is zero when the root table is + being filled, and drop is root when sub-tables are being filled. + + When a new sub-table is needed, it is necessary to look ahead in the + code lengths to determine what size sub-table is needed. The length + counts are used for this, and so count[] is decremented as codes are + entered in the tables. + + used keeps track of how many table entries have been allocated from the + provided *table space. It is checked for LENS and DIST tables against + the constants ENOUGH_LENS and ENOUGH_DISTS to guard against changes in + the initial root table size constants. See the comments in inftrees.h + for more information. + + sym increments through all symbols, and the loop terminates when + all codes of length max, i.e. all codes, have been processed. This + routine permits incomplete codes, so another loop after this one fills + in the rest of the decoding tables with invalid code markers. + */ + + /* set up for code type */ + // poor man optimization - use if-else instead of switch, + // to avoid deopts in old v8 + if (type === CODES) { + base = extra = work; /* dummy value--not used */ + match = 20; + + } else if (type === LENS) { + base = lbase; + extra = lext; + match = 257; + + } else { /* DISTS */ + base = dbase; + extra = dext; + match = 0; + } + + /* initialize opts for loop */ + huff = 0; /* starting code */ + sym = 0; /* starting code symbol */ + len = min; /* starting code length */ + next = table_index; /* current table to fill in */ + curr = root; /* current table index bits */ + drop = 0; /* current bits to drop from code for index */ + low = -1; /* trigger new sub-table when len > root */ + used = 1 << root; /* use root table entries */ + mask = used - 1; /* mask for comparing low */ + + /* check available table space */ + if ((type === LENS && used > ENOUGH_LENS) || + (type === DISTS && used > ENOUGH_DISTS)) { + return 1; + } + + /* process all codes and make table entries */ + for (;;) { + /* create table entry */ + here_bits = len - drop; + if (work[sym] + 1 < match) { + here_op = 0; + here_val = work[sym]; + } + else if (work[sym] >= match) { + here_op = extra[work[sym] - match]; + here_val = base[work[sym] - match]; + } + else { + here_op = 32 + 64; /* end of block */ + here_val = 0; + } + + /* replicate for those indices with low len bits equal to huff */ + incr = 1 << (len - drop); + fill = 1 << curr; + min = fill; /* save offset to next table */ + do { + fill -= incr; + table[next + (huff >> drop) + fill] = (here_bits << 24) | (here_op << 16) | here_val |0; + } while (fill !== 0); + + /* backwards increment the len-bit code huff */ + incr = 1 << (len - 1); + while (huff & incr) { + incr >>= 1; + } + if (incr !== 0) { + huff &= incr - 1; + huff += incr; + } else { + huff = 0; + } + + /* go to next symbol, update count, len */ + sym++; + if (--count[len] === 0) { + if (len === max) { break; } + len = lens[lens_index + work[sym]]; + } + + /* create new sub-table if needed */ + if (len > root && (huff & mask) !== low) { + /* if first time, transition to sub-tables */ + if (drop === 0) { + drop = root; + } + + /* increment past last table */ + next += min; /* here min is 1 << curr */ + + /* determine length of next table */ + curr = len - drop; + left = 1 << curr; + while (curr + drop < max) { + left -= count[curr + drop]; + if (left <= 0) { break; } + curr++; + left <<= 1; + } + + /* check for enough space */ + used += 1 << curr; + if ((type === LENS && used > ENOUGH_LENS) || + (type === DISTS && used > ENOUGH_DISTS)) { + return 1; + } + + /* point entry in root table to sub-table */ + low = huff & mask; + /*table.op[low] = curr; + table.bits[low] = root; + table.val[low] = next - opts.table_index;*/ + table[low] = (root << 24) | (curr << 16) | (next - table_index) |0; + } + } + + /* fill in remaining table entry if code is incomplete (guaranteed to have + at most one remaining entry, since if the code is incomplete, the + maximum code length that was allowed to get this far is one bit) */ + if (huff !== 0) { + //table.op[next + huff] = 64; /* invalid code marker */ + //table.bits[next + huff] = len - drop; + //table.val[next + huff] = 0; + table[next + huff] = ((len - drop) << 24) | (64 << 16) |0; + } + + /* set return parameters */ + //opts.table_index += used; + opts.bits = root; + return 0; +}; + + +module.exports = inflate_table; diff --git a/blue_modules/pako/lib/zlib/messages.js b/blue_modules/pako/lib/zlib/messages.js new file mode 100644 index 00000000000..426daec6b6e --- /dev/null +++ b/blue_modules/pako/lib/zlib/messages.js @@ -0,0 +1,32 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +module.exports = { + 2: 'need dictionary', /* Z_NEED_DICT 2 */ + 1: 'stream end', /* Z_STREAM_END 1 */ + 0: '', /* Z_OK 0 */ + '-1': 'file error', /* Z_ERRNO (-1) */ + '-2': 'stream error', /* Z_STREAM_ERROR (-2) */ + '-3': 'data error', /* Z_DATA_ERROR (-3) */ + '-4': 'insufficient memory', /* Z_MEM_ERROR (-4) */ + '-5': 'buffer error', /* Z_BUF_ERROR (-5) */ + '-6': 'incompatible version' /* Z_VERSION_ERROR (-6) */ +}; diff --git a/blue_modules/pako/lib/zlib/trees.js b/blue_modules/pako/lib/zlib/trees.js new file mode 100644 index 00000000000..300f1d832b9 --- /dev/null +++ b/blue_modules/pako/lib/zlib/trees.js @@ -0,0 +1,1179 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +/* eslint-disable space-unary-ops */ + +/* Public constants ==========================================================*/ +/* ===========================================================================*/ + + +//const Z_FILTERED = 1; +//const Z_HUFFMAN_ONLY = 2; +//const Z_RLE = 3; +const Z_FIXED = 4; +//const Z_DEFAULT_STRATEGY = 0; + +/* Possible values of the data_type field (though see inflate()) */ +const Z_BINARY = 0; +const Z_TEXT = 1; +//const Z_ASCII = 1; // = Z_TEXT +const Z_UNKNOWN = 2; + +/*============================================================================*/ + + +function zero(buf) { let len = buf.length; while (--len >= 0) { buf[len] = 0; } } + +// From zutil.h + +const STORED_BLOCK = 0; +const STATIC_TREES = 1; +const DYN_TREES = 2; +/* The three kinds of block type */ + +const MIN_MATCH = 3; +const MAX_MATCH = 258; +/* The minimum and maximum match lengths */ + +// From deflate.h +/* =========================================================================== + * Internal compression state. + */ + +const LENGTH_CODES = 29; +/* number of length codes, not counting the special END_BLOCK code */ + +const LITERALS = 256; +/* number of literal bytes 0..255 */ + +const L_CODES = LITERALS + 1 + LENGTH_CODES; +/* number of Literal or Length codes, including the END_BLOCK code */ + +const D_CODES = 30; +/* number of distance codes */ + +const BL_CODES = 19; +/* number of codes used to transfer the bit lengths */ + +const HEAP_SIZE = 2 * L_CODES + 1; +/* maximum heap size */ + +const MAX_BITS = 15; +/* All codes must not exceed MAX_BITS bits */ + +const Buf_size = 16; +/* size of bit buffer in bi_buf */ + + +/* =========================================================================== + * Constants + */ + +const MAX_BL_BITS = 7; +/* Bit length codes must not exceed MAX_BL_BITS bits */ + +const END_BLOCK = 256; +/* end of block literal code */ + +const REP_3_6 = 16; +/* repeat previous bit length 3-6 times (2 bits of repeat count) */ + +const REPZ_3_10 = 17; +/* repeat a zero length 3-10 times (3 bits of repeat count) */ + +const REPZ_11_138 = 18; +/* repeat a zero length 11-138 times (7 bits of repeat count) */ + +/* eslint-disable comma-spacing,array-bracket-spacing */ +const extra_lbits = /* extra bits for each length code */ + new Uint8Array([0,0,0,0,0,0,0,0,1,1,1,1,2,2,2,2,3,3,3,3,4,4,4,4,5,5,5,5,0]); + +const extra_dbits = /* extra bits for each distance code */ + new Uint8Array([0,0,0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7,8,8,9,9,10,10,11,11,12,12,13,13]); + +const extra_blbits = /* extra bits for each bit length code */ + new Uint8Array([0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,2,3,7]); + +const bl_order = + new Uint8Array([16,17,18,0,8,7,9,6,10,5,11,4,12,3,13,2,14,1,15]); +/* eslint-enable comma-spacing,array-bracket-spacing */ + +/* The lengths of the bit length codes are sent in order of decreasing + * probability, to avoid transmitting the lengths for unused bit length codes. + */ + +/* =========================================================================== + * Local data. These are initialized only once. + */ + +// We pre-fill arrays with 0 to avoid uninitialized gaps + +const DIST_CODE_LEN = 512; /* see definition of array dist_code below */ + +// !!!! Use flat array instead of structure, Freq = i*2, Len = i*2+1 +const static_ltree = new Array((L_CODES + 2) * 2); +zero(static_ltree); +/* The static literal tree. Since the bit lengths are imposed, there is no + * need for the L_CODES extra codes used during heap construction. However + * The codes 286 and 287 are needed to build a canonical tree (see _tr_init + * below). + */ + +const static_dtree = new Array(D_CODES * 2); +zero(static_dtree); +/* The static distance tree. (Actually a trivial tree since all codes use + * 5 bits.) + */ + +const _dist_code = new Array(DIST_CODE_LEN); +zero(_dist_code); +/* Distance codes. The first 256 values correspond to the distances + * 3 .. 258, the last 256 values correspond to the top 8 bits of + * the 15 bit distances. + */ + +const _length_code = new Array(MAX_MATCH - MIN_MATCH + 1); +zero(_length_code); +/* length code for each normalized match length (0 == MIN_MATCH) */ + +const base_length = new Array(LENGTH_CODES); +zero(base_length); +/* First normalized length for each code (0 = MIN_MATCH) */ + +const base_dist = new Array(D_CODES); +zero(base_dist); +/* First normalized distance for each code (0 = distance of 1) */ + + +function StaticTreeDesc(static_tree, extra_bits, extra_base, elems, max_length) { + + this.static_tree = static_tree; /* static tree or NULL */ + this.extra_bits = extra_bits; /* extra bits for each code or NULL */ + this.extra_base = extra_base; /* base index for extra_bits */ + this.elems = elems; /* max number of elements in the tree */ + this.max_length = max_length; /* max bit length for the codes */ + + // show if `static_tree` has data or dummy - needed for monomorphic objects + this.has_stree = static_tree && static_tree.length; +} + + +let static_l_desc; +let static_d_desc; +let static_bl_desc; + + +function TreeDesc(dyn_tree, stat_desc) { + this.dyn_tree = dyn_tree; /* the dynamic tree */ + this.max_code = 0; /* largest code with non zero frequency */ + this.stat_desc = stat_desc; /* the corresponding static tree */ +} + + + +const d_code = (dist) => { + + return dist < 256 ? _dist_code[dist] : _dist_code[256 + (dist >>> 7)]; +}; + + +/* =========================================================================== + * Output a short LSB first on the stream. + * IN assertion: there is enough room in pendingBuf. + */ +const put_short = (s, w) => { +// put_byte(s, (uch)((w) & 0xff)); +// put_byte(s, (uch)((ush)(w) >> 8)); + s.pending_buf[s.pending++] = (w) & 0xff; + s.pending_buf[s.pending++] = (w >>> 8) & 0xff; +}; + + +/* =========================================================================== + * Send a value on a given number of bits. + * IN assertion: length <= 16 and value fits in length bits. + */ +const send_bits = (s, value, length) => { + + if (s.bi_valid > (Buf_size - length)) { + s.bi_buf |= (value << s.bi_valid) & 0xffff; + put_short(s, s.bi_buf); + s.bi_buf = value >> (Buf_size - s.bi_valid); + s.bi_valid += length - Buf_size; + } else { + s.bi_buf |= (value << s.bi_valid) & 0xffff; + s.bi_valid += length; + } +}; + + +const send_code = (s, c, tree) => { + + send_bits(s, tree[c * 2]/*.Code*/, tree[c * 2 + 1]/*.Len*/); +}; + + +/* =========================================================================== + * Reverse the first len bits of a code, using straightforward code (a faster + * method would use a table) + * IN assertion: 1 <= len <= 15 + */ +const bi_reverse = (code, len) => { + + let res = 0; + do { + res |= code & 1; + code >>>= 1; + res <<= 1; + } while (--len > 0); + return res >>> 1; +}; + + +/* =========================================================================== + * Flush the bit buffer, keeping at most 7 bits in it. + */ +const bi_flush = (s) => { + + if (s.bi_valid === 16) { + put_short(s, s.bi_buf); + s.bi_buf = 0; + s.bi_valid = 0; + + } else if (s.bi_valid >= 8) { + s.pending_buf[s.pending++] = s.bi_buf & 0xff; + s.bi_buf >>= 8; + s.bi_valid -= 8; + } +}; + + +/* =========================================================================== + * Compute the optimal bit lengths for a tree and update the total bit length + * for the current block. + * IN assertion: the fields freq and dad are set, heap[heap_max] and + * above are the tree nodes sorted by increasing frequency. + * OUT assertions: the field len is set to the optimal bit length, the + * array bl_count contains the frequencies for each bit length. + * The length opt_len is updated; static_len is also updated if stree is + * not null. + */ +const gen_bitlen = (s, desc) => { +// deflate_state *s; +// tree_desc *desc; /* the tree descriptor */ + + const tree = desc.dyn_tree; + const max_code = desc.max_code; + const stree = desc.stat_desc.static_tree; + const has_stree = desc.stat_desc.has_stree; + const extra = desc.stat_desc.extra_bits; + const base = desc.stat_desc.extra_base; + const max_length = desc.stat_desc.max_length; + let h; /* heap index */ + let n, m; /* iterate over the tree elements */ + let bits; /* bit length */ + let xbits; /* extra bits */ + let f; /* frequency */ + let overflow = 0; /* number of elements with bit length too large */ + + for (bits = 0; bits <= MAX_BITS; bits++) { + s.bl_count[bits] = 0; + } + + /* In a first pass, compute the optimal bit lengths (which may + * overflow in the case of the bit length tree). + */ + tree[s.heap[s.heap_max] * 2 + 1]/*.Len*/ = 0; /* root of the heap */ + + for (h = s.heap_max + 1; h < HEAP_SIZE; h++) { + n = s.heap[h]; + bits = tree[tree[n * 2 + 1]/*.Dad*/ * 2 + 1]/*.Len*/ + 1; + if (bits > max_length) { + bits = max_length; + overflow++; + } + tree[n * 2 + 1]/*.Len*/ = bits; + /* We overwrite tree[n].Dad which is no longer needed */ + + if (n > max_code) { continue; } /* not a leaf node */ + + s.bl_count[bits]++; + xbits = 0; + if (n >= base) { + xbits = extra[n - base]; + } + f = tree[n * 2]/*.Freq*/; + s.opt_len += f * (bits + xbits); + if (has_stree) { + s.static_len += f * (stree[n * 2 + 1]/*.Len*/ + xbits); + } + } + if (overflow === 0) { return; } + + // Tracev((stderr,"\nbit length overflow\n")); + /* This happens for example on obj2 and pic of the Calgary corpus */ + + /* Find the first bit length which could increase: */ + do { + bits = max_length - 1; + while (s.bl_count[bits] === 0) { bits--; } + s.bl_count[bits]--; /* move one leaf down the tree */ + s.bl_count[bits + 1] += 2; /* move one overflow item as its brother */ + s.bl_count[max_length]--; + /* The brother of the overflow item also moves one step up, + * but this does not affect bl_count[max_length] + */ + overflow -= 2; + } while (overflow > 0); + + /* Now recompute all bit lengths, scanning in increasing frequency. + * h is still equal to HEAP_SIZE. (It is simpler to reconstruct all + * lengths instead of fixing only the wrong ones. This idea is taken + * from 'ar' written by Haruhiko Okumura.) + */ + for (bits = max_length; bits !== 0; bits--) { + n = s.bl_count[bits]; + while (n !== 0) { + m = s.heap[--h]; + if (m > max_code) { continue; } + if (tree[m * 2 + 1]/*.Len*/ !== bits) { + // Tracev((stderr,"code %d bits %d->%d\n", m, tree[m].Len, bits)); + s.opt_len += (bits - tree[m * 2 + 1]/*.Len*/) * tree[m * 2]/*.Freq*/; + tree[m * 2 + 1]/*.Len*/ = bits; + } + n--; + } + } +}; + + +/* =========================================================================== + * Generate the codes for a given tree and bit counts (which need not be + * optimal). + * IN assertion: the array bl_count contains the bit length statistics for + * the given tree and the field len is set for all tree elements. + * OUT assertion: the field code is set for all tree elements of non + * zero code length. + */ +const gen_codes = (tree, max_code, bl_count) => { +// ct_data *tree; /* the tree to decorate */ +// int max_code; /* largest code with non zero frequency */ +// ushf *bl_count; /* number of codes at each bit length */ + + const next_code = new Array(MAX_BITS + 1); /* next code value for each bit length */ + let code = 0; /* running code value */ + let bits; /* bit index */ + let n; /* code index */ + + /* The distribution counts are first used to generate the code values + * without bit reversal. + */ + for (bits = 1; bits <= MAX_BITS; bits++) { + code = (code + bl_count[bits - 1]) << 1; + next_code[bits] = code; + } + /* Check that the bit counts in bl_count are consistent. The last code + * must be all ones. + */ + //Assert (code + bl_count[MAX_BITS]-1 == (1< { + + let n; /* iterates over tree elements */ + let bits; /* bit counter */ + let length; /* length value */ + let code; /* code value */ + let dist; /* distance index */ + const bl_count = new Array(MAX_BITS + 1); + /* number of codes at each bit length for an optimal tree */ + + // do check in _tr_init() + //if (static_init_done) return; + + /* For some embedded targets, global variables are not initialized: */ +/*#ifdef NO_INIT_GLOBAL_POINTERS + static_l_desc.static_tree = static_ltree; + static_l_desc.extra_bits = extra_lbits; + static_d_desc.static_tree = static_dtree; + static_d_desc.extra_bits = extra_dbits; + static_bl_desc.extra_bits = extra_blbits; +#endif*/ + + /* Initialize the mapping length (0..255) -> length code (0..28) */ + length = 0; + for (code = 0; code < LENGTH_CODES - 1; code++) { + base_length[code] = length; + for (n = 0; n < (1 << extra_lbits[code]); n++) { + _length_code[length++] = code; + } + } + //Assert (length == 256, "tr_static_init: length != 256"); + /* Note that the length 255 (match length 258) can be represented + * in two different ways: code 284 + 5 bits or code 285, so we + * overwrite length_code[255] to use the best encoding: + */ + _length_code[length - 1] = code; + + /* Initialize the mapping dist (0..32K) -> dist code (0..29) */ + dist = 0; + for (code = 0; code < 16; code++) { + base_dist[code] = dist; + for (n = 0; n < (1 << extra_dbits[code]); n++) { + _dist_code[dist++] = code; + } + } + //Assert (dist == 256, "tr_static_init: dist != 256"); + dist >>= 7; /* from now on, all distances are divided by 128 */ + for (; code < D_CODES; code++) { + base_dist[code] = dist << 7; + for (n = 0; n < (1 << (extra_dbits[code] - 7)); n++) { + _dist_code[256 + dist++] = code; + } + } + //Assert (dist == 256, "tr_static_init: 256+dist != 512"); + + /* Construct the codes of the static literal tree */ + for (bits = 0; bits <= MAX_BITS; bits++) { + bl_count[bits] = 0; + } + + n = 0; + while (n <= 143) { + static_ltree[n * 2 + 1]/*.Len*/ = 8; + n++; + bl_count[8]++; + } + while (n <= 255) { + static_ltree[n * 2 + 1]/*.Len*/ = 9; + n++; + bl_count[9]++; + } + while (n <= 279) { + static_ltree[n * 2 + 1]/*.Len*/ = 7; + n++; + bl_count[7]++; + } + while (n <= 287) { + static_ltree[n * 2 + 1]/*.Len*/ = 8; + n++; + bl_count[8]++; + } + /* Codes 286 and 287 do not exist, but we must include them in the + * tree construction to get a canonical Huffman tree (longest code + * all ones) + */ + gen_codes(static_ltree, L_CODES + 1, bl_count); + + /* The static distance tree is trivial: */ + for (n = 0; n < D_CODES; n++) { + static_dtree[n * 2 + 1]/*.Len*/ = 5; + static_dtree[n * 2]/*.Code*/ = bi_reverse(n, 5); + } + + // Now data ready and we can init static trees + static_l_desc = new StaticTreeDesc(static_ltree, extra_lbits, LITERALS + 1, L_CODES, MAX_BITS); + static_d_desc = new StaticTreeDesc(static_dtree, extra_dbits, 0, D_CODES, MAX_BITS); + static_bl_desc = new StaticTreeDesc(new Array(0), extra_blbits, 0, BL_CODES, MAX_BL_BITS); + + //static_init_done = true; +}; + + +/* =========================================================================== + * Initialize a new block. + */ +const init_block = (s) => { + + let n; /* iterates over tree elements */ + + /* Initialize the trees. */ + for (n = 0; n < L_CODES; n++) { s.dyn_ltree[n * 2]/*.Freq*/ = 0; } + for (n = 0; n < D_CODES; n++) { s.dyn_dtree[n * 2]/*.Freq*/ = 0; } + for (n = 0; n < BL_CODES; n++) { s.bl_tree[n * 2]/*.Freq*/ = 0; } + + s.dyn_ltree[END_BLOCK * 2]/*.Freq*/ = 1; + s.opt_len = s.static_len = 0; + s.sym_next = s.matches = 0; +}; + + +/* =========================================================================== + * Flush the bit buffer and align the output on a byte boundary + */ +const bi_windup = (s) => +{ + if (s.bi_valid > 8) { + put_short(s, s.bi_buf); + } else if (s.bi_valid > 0) { + //put_byte(s, (Byte)s->bi_buf); + s.pending_buf[s.pending++] = s.bi_buf; + } + s.bi_buf = 0; + s.bi_valid = 0; +}; + +/* =========================================================================== + * Compares to subtrees, using the tree depth as tie breaker when + * the subtrees have equal frequency. This minimizes the worst case length. + */ +const smaller = (tree, n, m, depth) => { + + const _n2 = n * 2; + const _m2 = m * 2; + return (tree[_n2]/*.Freq*/ < tree[_m2]/*.Freq*/ || + (tree[_n2]/*.Freq*/ === tree[_m2]/*.Freq*/ && depth[n] <= depth[m])); +}; + +/* =========================================================================== + * Restore the heap property by moving down the tree starting at node k, + * exchanging a node with the smallest of its two sons if necessary, stopping + * when the heap property is re-established (each father smaller than its + * two sons). + */ +const pqdownheap = (s, tree, k) => { +// deflate_state *s; +// ct_data *tree; /* the tree to restore */ +// int k; /* node to move down */ + + const v = s.heap[k]; + let j = k << 1; /* left son of k */ + while (j <= s.heap_len) { + /* Set j to the smallest of the two sons: */ + if (j < s.heap_len && + smaller(tree, s.heap[j + 1], s.heap[j], s.depth)) { + j++; + } + /* Exit if v is smaller than both sons */ + if (smaller(tree, v, s.heap[j], s.depth)) { break; } + + /* Exchange v with the smallest son */ + s.heap[k] = s.heap[j]; + k = j; + + /* And continue down the tree, setting j to the left son of k */ + j <<= 1; + } + s.heap[k] = v; +}; + + +// inlined manually +// const SMALLEST = 1; + +/* =========================================================================== + * Send the block data compressed using the given Huffman trees + */ +const compress_block = (s, ltree, dtree) => { +// deflate_state *s; +// const ct_data *ltree; /* literal tree */ +// const ct_data *dtree; /* distance tree */ + + let dist; /* distance of matched string */ + let lc; /* match length or unmatched char (if dist == 0) */ + let sx = 0; /* running index in sym_buf */ + let code; /* the code to send */ + let extra; /* number of extra bits to send */ + + if (s.sym_next !== 0) { + do { + dist = s.pending_buf[s.sym_buf + sx++] & 0xff; + dist += (s.pending_buf[s.sym_buf + sx++] & 0xff) << 8; + lc = s.pending_buf[s.sym_buf + sx++]; + if (dist === 0) { + send_code(s, lc, ltree); /* send a literal byte */ + //Tracecv(isgraph(lc), (stderr," '%c' ", lc)); + } else { + /* Here, lc is the match length - MIN_MATCH */ + code = _length_code[lc]; + send_code(s, code + LITERALS + 1, ltree); /* send the length code */ + extra = extra_lbits[code]; + if (extra !== 0) { + lc -= base_length[code]; + send_bits(s, lc, extra); /* send the extra length bits */ + } + dist--; /* dist is now the match distance - 1 */ + code = d_code(dist); + //Assert (code < D_CODES, "bad d_code"); + + send_code(s, code, dtree); /* send the distance code */ + extra = extra_dbits[code]; + if (extra !== 0) { + dist -= base_dist[code]; + send_bits(s, dist, extra); /* send the extra distance bits */ + } + } /* literal or match pair ? */ + + /* Check that the overlay between pending_buf and sym_buf is ok: */ + //Assert(s->pending < s->lit_bufsize + sx, "pendingBuf overflow"); + + } while (sx < s.sym_next); + } + + send_code(s, END_BLOCK, ltree); +}; + + +/* =========================================================================== + * Construct one Huffman tree and assigns the code bit strings and lengths. + * Update the total bit length for the current block. + * IN assertion: the field freq is set for all tree elements. + * OUT assertions: the fields len and code are set to the optimal bit length + * and corresponding code. The length opt_len is updated; static_len is + * also updated if stree is not null. The field max_code is set. + */ +const build_tree = (s, desc) => { +// deflate_state *s; +// tree_desc *desc; /* the tree descriptor */ + + const tree = desc.dyn_tree; + const stree = desc.stat_desc.static_tree; + const has_stree = desc.stat_desc.has_stree; + const elems = desc.stat_desc.elems; + let n, m; /* iterate over heap elements */ + let max_code = -1; /* largest code with non zero frequency */ + let node; /* new node being created */ + + /* Construct the initial heap, with least frequent element in + * heap[SMALLEST]. The sons of heap[n] are heap[2*n] and heap[2*n+1]. + * heap[0] is not used. + */ + s.heap_len = 0; + s.heap_max = HEAP_SIZE; + + for (n = 0; n < elems; n++) { + if (tree[n * 2]/*.Freq*/ !== 0) { + s.heap[++s.heap_len] = max_code = n; + s.depth[n] = 0; + + } else { + tree[n * 2 + 1]/*.Len*/ = 0; + } + } + + /* The pkzip format requires that at least one distance code exists, + * and that at least one bit should be sent even if there is only one + * possible code. So to avoid special checks later on we force at least + * two codes of non zero frequency. + */ + while (s.heap_len < 2) { + node = s.heap[++s.heap_len] = (max_code < 2 ? ++max_code : 0); + tree[node * 2]/*.Freq*/ = 1; + s.depth[node] = 0; + s.opt_len--; + + if (has_stree) { + s.static_len -= stree[node * 2 + 1]/*.Len*/; + } + /* node is 0 or 1 so it does not have extra bits */ + } + desc.max_code = max_code; + + /* The elements heap[heap_len/2+1 .. heap_len] are leaves of the tree, + * establish sub-heaps of increasing lengths: + */ + for (n = (s.heap_len >> 1/*int /2*/); n >= 1; n--) { pqdownheap(s, tree, n); } + + /* Construct the Huffman tree by repeatedly combining the least two + * frequent nodes. + */ + node = elems; /* next internal node of the tree */ + do { + //pqremove(s, tree, n); /* n = node of least frequency */ + /*** pqremove ***/ + n = s.heap[1/*SMALLEST*/]; + s.heap[1/*SMALLEST*/] = s.heap[s.heap_len--]; + pqdownheap(s, tree, 1/*SMALLEST*/); + /***/ + + m = s.heap[1/*SMALLEST*/]; /* m = node of next least frequency */ + + s.heap[--s.heap_max] = n; /* keep the nodes sorted by frequency */ + s.heap[--s.heap_max] = m; + + /* Create a new node father of n and m */ + tree[node * 2]/*.Freq*/ = tree[n * 2]/*.Freq*/ + tree[m * 2]/*.Freq*/; + s.depth[node] = (s.depth[n] >= s.depth[m] ? s.depth[n] : s.depth[m]) + 1; + tree[n * 2 + 1]/*.Dad*/ = tree[m * 2 + 1]/*.Dad*/ = node; + + /* and insert the new node in the heap */ + s.heap[1/*SMALLEST*/] = node++; + pqdownheap(s, tree, 1/*SMALLEST*/); + + } while (s.heap_len >= 2); + + s.heap[--s.heap_max] = s.heap[1/*SMALLEST*/]; + + /* At this point, the fields freq and dad are set. We can now + * generate the bit lengths. + */ + gen_bitlen(s, desc); + + /* The field len is now set, we can generate the bit codes */ + gen_codes(tree, max_code, s.bl_count); +}; + + +/* =========================================================================== + * Scan a literal or distance tree to determine the frequencies of the codes + * in the bit length tree. + */ +const scan_tree = (s, tree, max_code) => { +// deflate_state *s; +// ct_data *tree; /* the tree to be scanned */ +// int max_code; /* and its largest code of non zero frequency */ + + let n; /* iterates over all tree elements */ + let prevlen = -1; /* last emitted length */ + let curlen; /* length of current code */ + + let nextlen = tree[0 * 2 + 1]/*.Len*/; /* length of next code */ + + let count = 0; /* repeat count of the current code */ + let max_count = 7; /* max repeat count */ + let min_count = 4; /* min repeat count */ + + if (nextlen === 0) { + max_count = 138; + min_count = 3; + } + tree[(max_code + 1) * 2 + 1]/*.Len*/ = 0xffff; /* guard */ + + for (n = 0; n <= max_code; n++) { + curlen = nextlen; + nextlen = tree[(n + 1) * 2 + 1]/*.Len*/; + + if (++count < max_count && curlen === nextlen) { + continue; + + } else if (count < min_count) { + s.bl_tree[curlen * 2]/*.Freq*/ += count; + + } else if (curlen !== 0) { + + if (curlen !== prevlen) { s.bl_tree[curlen * 2]/*.Freq*/++; } + s.bl_tree[REP_3_6 * 2]/*.Freq*/++; + + } else if (count <= 10) { + s.bl_tree[REPZ_3_10 * 2]/*.Freq*/++; + + } else { + s.bl_tree[REPZ_11_138 * 2]/*.Freq*/++; + } + + count = 0; + prevlen = curlen; + + if (nextlen === 0) { + max_count = 138; + min_count = 3; + + } else if (curlen === nextlen) { + max_count = 6; + min_count = 3; + + } else { + max_count = 7; + min_count = 4; + } + } +}; + + +/* =========================================================================== + * Send a literal or distance tree in compressed form, using the codes in + * bl_tree. + */ +const send_tree = (s, tree, max_code) => { +// deflate_state *s; +// ct_data *tree; /* the tree to be scanned */ +// int max_code; /* and its largest code of non zero frequency */ + + let n; /* iterates over all tree elements */ + let prevlen = -1; /* last emitted length */ + let curlen; /* length of current code */ + + let nextlen = tree[0 * 2 + 1]/*.Len*/; /* length of next code */ + + let count = 0; /* repeat count of the current code */ + let max_count = 7; /* max repeat count */ + let min_count = 4; /* min repeat count */ + + /* tree[max_code+1].Len = -1; */ /* guard already set */ + if (nextlen === 0) { + max_count = 138; + min_count = 3; + } + + for (n = 0; n <= max_code; n++) { + curlen = nextlen; + nextlen = tree[(n + 1) * 2 + 1]/*.Len*/; + + if (++count < max_count && curlen === nextlen) { + continue; + + } else if (count < min_count) { + do { send_code(s, curlen, s.bl_tree); } while (--count !== 0); + + } else if (curlen !== 0) { + if (curlen !== prevlen) { + send_code(s, curlen, s.bl_tree); + count--; + } + //Assert(count >= 3 && count <= 6, " 3_6?"); + send_code(s, REP_3_6, s.bl_tree); + send_bits(s, count - 3, 2); + + } else if (count <= 10) { + send_code(s, REPZ_3_10, s.bl_tree); + send_bits(s, count - 3, 3); + + } else { + send_code(s, REPZ_11_138, s.bl_tree); + send_bits(s, count - 11, 7); + } + + count = 0; + prevlen = curlen; + if (nextlen === 0) { + max_count = 138; + min_count = 3; + + } else if (curlen === nextlen) { + max_count = 6; + min_count = 3; + + } else { + max_count = 7; + min_count = 4; + } + } +}; + + +/* =========================================================================== + * Construct the Huffman tree for the bit lengths and return the index in + * bl_order of the last bit length code to send. + */ +const build_bl_tree = (s) => { + + let max_blindex; /* index of last bit length code of non zero freq */ + + /* Determine the bit length frequencies for literal and distance trees */ + scan_tree(s, s.dyn_ltree, s.l_desc.max_code); + scan_tree(s, s.dyn_dtree, s.d_desc.max_code); + + /* Build the bit length tree: */ + build_tree(s, s.bl_desc); + /* opt_len now includes the length of the tree representations, except + * the lengths of the bit lengths codes and the 5+5+4 bits for the counts. + */ + + /* Determine the number of bit length codes to send. The pkzip format + * requires that at least 4 bit length codes be sent. (appnote.txt says + * 3 but the actual value used is 4.) + */ + for (max_blindex = BL_CODES - 1; max_blindex >= 3; max_blindex--) { + if (s.bl_tree[bl_order[max_blindex] * 2 + 1]/*.Len*/ !== 0) { + break; + } + } + /* Update opt_len to include the bit length tree and counts */ + s.opt_len += 3 * (max_blindex + 1) + 5 + 5 + 4; + //Tracev((stderr, "\ndyn trees: dyn %ld, stat %ld", + // s->opt_len, s->static_len)); + + return max_blindex; +}; + + +/* =========================================================================== + * Send the header for a block using dynamic Huffman trees: the counts, the + * lengths of the bit length codes, the literal tree and the distance tree. + * IN assertion: lcodes >= 257, dcodes >= 1, blcodes >= 4. + */ +const send_all_trees = (s, lcodes, dcodes, blcodes) => { +// deflate_state *s; +// int lcodes, dcodes, blcodes; /* number of codes for each tree */ + + let rank; /* index in bl_order */ + + //Assert (lcodes >= 257 && dcodes >= 1 && blcodes >= 4, "not enough codes"); + //Assert (lcodes <= L_CODES && dcodes <= D_CODES && blcodes <= BL_CODES, + // "too many codes"); + //Tracev((stderr, "\nbl counts: ")); + send_bits(s, lcodes - 257, 5); /* not +255 as stated in appnote.txt */ + send_bits(s, dcodes - 1, 5); + send_bits(s, blcodes - 4, 4); /* not -3 as stated in appnote.txt */ + for (rank = 0; rank < blcodes; rank++) { + //Tracev((stderr, "\nbl code %2d ", bl_order[rank])); + send_bits(s, s.bl_tree[bl_order[rank] * 2 + 1]/*.Len*/, 3); + } + //Tracev((stderr, "\nbl tree: sent %ld", s->bits_sent)); + + send_tree(s, s.dyn_ltree, lcodes - 1); /* literal tree */ + //Tracev((stderr, "\nlit tree: sent %ld", s->bits_sent)); + + send_tree(s, s.dyn_dtree, dcodes - 1); /* distance tree */ + //Tracev((stderr, "\ndist tree: sent %ld", s->bits_sent)); +}; + + +/* =========================================================================== + * Check if the data type is TEXT or BINARY, using the following algorithm: + * - TEXT if the two conditions below are satisfied: + * a) There are no non-portable control characters belonging to the + * "block list" (0..6, 14..25, 28..31). + * b) There is at least one printable character belonging to the + * "allow list" (9 {TAB}, 10 {LF}, 13 {CR}, 32..255). + * - BINARY otherwise. + * - The following partially-portable control characters form a + * "gray list" that is ignored in this detection algorithm: + * (7 {BEL}, 8 {BS}, 11 {VT}, 12 {FF}, 26 {SUB}, 27 {ESC}). + * IN assertion: the fields Freq of dyn_ltree are set. + */ +const detect_data_type = (s) => { + /* block_mask is the bit mask of block-listed bytes + * set bits 0..6, 14..25, and 28..31 + * 0xf3ffc07f = binary 11110011111111111100000001111111 + */ + let block_mask = 0xf3ffc07f; + let n; + + /* Check for non-textual ("block-listed") bytes. */ + for (n = 0; n <= 31; n++, block_mask >>>= 1) { + if ((block_mask & 1) && (s.dyn_ltree[n * 2]/*.Freq*/ !== 0)) { + return Z_BINARY; + } + } + + /* Check for textual ("allow-listed") bytes. */ + if (s.dyn_ltree[9 * 2]/*.Freq*/ !== 0 || s.dyn_ltree[10 * 2]/*.Freq*/ !== 0 || + s.dyn_ltree[13 * 2]/*.Freq*/ !== 0) { + return Z_TEXT; + } + for (n = 32; n < LITERALS; n++) { + if (s.dyn_ltree[n * 2]/*.Freq*/ !== 0) { + return Z_TEXT; + } + } + + /* There are no "block-listed" or "allow-listed" bytes: + * this stream either is empty or has tolerated ("gray-listed") bytes only. + */ + return Z_BINARY; +}; + + +let static_init_done = false; + +/* =========================================================================== + * Initialize the tree data structures for a new zlib stream. + */ +const _tr_init = (s) => +{ + + if (!static_init_done) { + tr_static_init(); + static_init_done = true; + } + + s.l_desc = new TreeDesc(s.dyn_ltree, static_l_desc); + s.d_desc = new TreeDesc(s.dyn_dtree, static_d_desc); + s.bl_desc = new TreeDesc(s.bl_tree, static_bl_desc); + + s.bi_buf = 0; + s.bi_valid = 0; + + /* Initialize the first block of the first file: */ + init_block(s); +}; + + +/* =========================================================================== + * Send a stored block + */ +const _tr_stored_block = (s, buf, stored_len, last) => { +//DeflateState *s; +//charf *buf; /* input block */ +//ulg stored_len; /* length of input block */ +//int last; /* one if this is the last block for a file */ + + send_bits(s, (STORED_BLOCK << 1) + (last ? 1 : 0), 3); /* send block type */ + bi_windup(s); /* align on byte boundary */ + put_short(s, stored_len); + put_short(s, ~stored_len); + if (stored_len) { + s.pending_buf.set(s.window.subarray(buf, buf + stored_len), s.pending); + } + s.pending += stored_len; +}; + + +/* =========================================================================== + * Send one empty static block to give enough lookahead for inflate. + * This takes 10 bits, of which 7 may remain in the bit buffer. + */ +const _tr_align = (s) => { + send_bits(s, STATIC_TREES << 1, 3); + send_code(s, END_BLOCK, static_ltree); + bi_flush(s); +}; + + +/* =========================================================================== + * Determine the best encoding for the current block: dynamic trees, static + * trees or store, and write out the encoded block. + */ +const _tr_flush_block = (s, buf, stored_len, last) => { +//DeflateState *s; +//charf *buf; /* input block, or NULL if too old */ +//ulg stored_len; /* length of input block */ +//int last; /* one if this is the last block for a file */ + + let opt_lenb, static_lenb; /* opt_len and static_len in bytes */ + let max_blindex = 0; /* index of last bit length code of non zero freq */ + + /* Build the Huffman trees unless a stored block is forced */ + if (s.level > 0) { + + /* Check if the file is binary or text */ + if (s.strm.data_type === Z_UNKNOWN) { + s.strm.data_type = detect_data_type(s); + } + + /* Construct the literal and distance trees */ + build_tree(s, s.l_desc); + // Tracev((stderr, "\nlit data: dyn %ld, stat %ld", s->opt_len, + // s->static_len)); + + build_tree(s, s.d_desc); + // Tracev((stderr, "\ndist data: dyn %ld, stat %ld", s->opt_len, + // s->static_len)); + /* At this point, opt_len and static_len are the total bit lengths of + * the compressed block data, excluding the tree representations. + */ + + /* Build the bit length tree for the above two trees, and get the index + * in bl_order of the last bit length code to send. + */ + max_blindex = build_bl_tree(s); + + /* Determine the best encoding. Compute the block lengths in bytes. */ + opt_lenb = (s.opt_len + 3 + 7) >>> 3; + static_lenb = (s.static_len + 3 + 7) >>> 3; + + // Tracev((stderr, "\nopt %lu(%lu) stat %lu(%lu) stored %lu lit %u ", + // opt_lenb, s->opt_len, static_lenb, s->static_len, stored_len, + // s->sym_next / 3)); + + if (static_lenb <= opt_lenb) { opt_lenb = static_lenb; } + + } else { + // Assert(buf != (char*)0, "lost buf"); + opt_lenb = static_lenb = stored_len + 5; /* force a stored block */ + } + + if ((stored_len + 4 <= opt_lenb) && (buf !== -1)) { + /* 4: two words for the lengths */ + + /* The test buf != NULL is only necessary if LIT_BUFSIZE > WSIZE. + * Otherwise we can't have processed more than WSIZE input bytes since + * the last block flush, because compression would have been + * successful. If LIT_BUFSIZE <= WSIZE, it is never too late to + * transform a block into a stored block. + */ + _tr_stored_block(s, buf, stored_len, last); + + } else if (s.strategy === Z_FIXED || static_lenb === opt_lenb) { + + send_bits(s, (STATIC_TREES << 1) + (last ? 1 : 0), 3); + compress_block(s, static_ltree, static_dtree); + + } else { + send_bits(s, (DYN_TREES << 1) + (last ? 1 : 0), 3); + send_all_trees(s, s.l_desc.max_code + 1, s.d_desc.max_code + 1, max_blindex + 1); + compress_block(s, s.dyn_ltree, s.dyn_dtree); + } + // Assert (s->compressed_len == s->bits_sent, "bad compressed size"); + /* The above check is made mod 2^32, for files larger than 512 MB + * and uLong implemented on 32 bits. + */ + init_block(s); + + if (last) { + bi_windup(s); + } + // Tracev((stderr,"\ncomprlen %lu(%lu) ", s->compressed_len>>3, + // s->compressed_len-7*last)); +}; + +/* =========================================================================== + * Save the match info and tally the frequency counts. Return true if + * the current block must be flushed. + */ +const _tr_tally = (s, dist, lc) => { +// deflate_state *s; +// unsigned dist; /* distance of matched string */ +// unsigned lc; /* match length-MIN_MATCH or unmatched char (if dist==0) */ + + s.pending_buf[s.sym_buf + s.sym_next++] = dist; + s.pending_buf[s.sym_buf + s.sym_next++] = dist >> 8; + s.pending_buf[s.sym_buf + s.sym_next++] = lc; + if (dist === 0) { + /* lc is the unmatched char */ + s.dyn_ltree[lc * 2]/*.Freq*/++; + } else { + s.matches++; + /* Here, lc is the match length - MIN_MATCH */ + dist--; /* dist = match distance - 1 */ + //Assert((ush)dist < (ush)MAX_DIST(s) && + // (ush)lc <= (ush)(MAX_MATCH-MIN_MATCH) && + // (ush)d_code(dist) < (ush)D_CODES, "_tr_tally: bad match"); + + s.dyn_ltree[(_length_code[lc] + LITERALS + 1) * 2]/*.Freq*/++; + s.dyn_dtree[d_code(dist) * 2]/*.Freq*/++; + } + + return (s.sym_next === s.sym_end); +}; + +module.exports._tr_init = _tr_init; +module.exports._tr_stored_block = _tr_stored_block; +module.exports._tr_flush_block = _tr_flush_block; +module.exports._tr_tally = _tr_tally; +module.exports._tr_align = _tr_align; diff --git a/blue_modules/pako/lib/zlib/zstream.js b/blue_modules/pako/lib/zlib/zstream.js new file mode 100644 index 00000000000..122acfef0f7 --- /dev/null +++ b/blue_modules/pako/lib/zlib/zstream.js @@ -0,0 +1,47 @@ +'use strict'; + +// (C) 1995-2013 Jean-loup Gailly and Mark Adler +// (C) 2014-2017 Vitaly Puzrin and Andrey Tupitsin +// +// This software is provided 'as-is', without any express or implied +// warranty. In no event will the authors be held liable for any damages +// arising from the use of this software. +// +// Permission is granted to anyone to use this software for any purpose, +// including commercial applications, and to alter it and redistribute it +// freely, subject to the following restrictions: +// +// 1. The origin of this software must not be misrepresented; you must not +// claim that you wrote the original software. If you use this software +// in a product, an acknowledgment in the product documentation would be +// appreciated but is not required. +// 2. Altered source versions must be plainly marked as such, and must not be +// misrepresented as being the original software. +// 3. This notice may not be removed or altered from any source distribution. + +function ZStream() { + /* next input byte */ + this.input = null; // JS specific, because we have no pointers + this.next_in = 0; + /* number of bytes available at input */ + this.avail_in = 0; + /* total number of input bytes read so far */ + this.total_in = 0; + /* next output byte should be put there */ + this.output = null; // JS specific, because we have no pointers + this.next_out = 0; + /* remaining free space at output */ + this.avail_out = 0; + /* total number of bytes output so far */ + this.total_out = 0; + /* last error message, NULL if no error */ + this.msg = ''/*Z_NULL*/; + /* not visible by applications */ + this.state = null; + /* best guess about the data type: binary or text */ + this.data_type = 2/*Z_UNKNOWN*/; + /* adler32 value of the uncompressed data */ + this.adler = 0; +} + +module.exports = ZStream; diff --git a/blue_modules/pako/package.json b/blue_modules/pako/package.json new file mode 100644 index 00000000000..1673520bc3d --- /dev/null +++ b/blue_modules/pako/package.json @@ -0,0 +1,41 @@ +{ + "name": "pako", + "description": "zlib port to javascript - fast, modularized, with browser support", + "version": "2.1.0", + "keywords": [ + "zlib", + "deflate", + "inflate", + "gzip" + ], + "contributors": [ + "Andrei Tuputcyn (https://github.com/andr83)", + "Vitaly Puzrin (https://github.com/puzrin)", + "Friedel Ziegelmayer (https://github.com/dignifiedquire)", + "Kirill Efimov (https://github.com/Kirill89)", + "Jean-loup Gailly", + "Mark Adler" + ], + "files": [ + "index.js", + "dist/", + "lib/" + ], + "license": "(MIT AND Zlib)", + "repository": "nodeca/pako", + "module": "./dist/pako.esm.mjs", + "exports": { + ".": { + "import": "./dist/pako.esm.mjs", + "require": "./index.js" + }, + "./package.json": "./package.json", + "./dist/*": "./dist/*", + "./lib/*": "./lib/*", + "./lib/zlib/*": "./lib/zlib/*", + "./lib/utils/*": "./lib/utils/*" + }, + "scripts": {}, + "devDependencies": {}, + "dependencies": {} +} diff --git a/blue_modules/react-native-bw-file-access/.gitignore b/blue_modules/react-native-bw-file-access/.gitignore new file mode 100644 index 00000000000..a1b76a8a6fa --- /dev/null +++ b/blue_modules/react-native-bw-file-access/.gitignore @@ -0,0 +1,42 @@ +# OSX +# +.DS_Store +**/package-lock.json +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml + +# BUCK +buck-out/ +\.buckd/ +*.keystore diff --git a/blue_modules/react-native-bw-file-access/README.md b/blue_modules/react-native-bw-file-access/README.md new file mode 100644 index 00000000000..3e86cf135c7 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/README.md @@ -0,0 +1,6 @@ +# react-native-bw-file-access + +A custom package written to allow BlueWallet to open files directly from the Files app in iOS. We make use of `startAccessingSecurityScopedResource()` and `stopAccessingSecurityScopedResource()`. + +Read Apple's documentation to understand more about the Open-in-Place mechanics for accessing files which are not in an apps sandbox environment. +[Link here](https://developer.apple.com/documentation/uikit/documents_data_and_pasteboard/synchronizing_documents_in_the_icloud_environment#3743499). diff --git a/blue_modules/react-native-bw-file-access/index.ts b/blue_modules/react-native-bw-file-access/index.ts new file mode 100644 index 00000000000..6cd5303826a --- /dev/null +++ b/blue_modules/react-native-bw-file-access/index.ts @@ -0,0 +1,11 @@ +// main index.js + +import { NativeModules } from 'react-native'; + +const { BwFileAccess } = NativeModules; + +export function readFile(filePath: string): Promise { + return BwFileAccess.readFileContent(filePath); +} + +export default BwFileAccess; diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h new file mode 100644 index 00000000000..0ecd3f53c1e --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.h @@ -0,0 +1,7 @@ +// BwFileAccess.h + +#import + +@interface BwFileAccess : NSObject + +@end diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m new file mode 100644 index 00000000000..8081536ebdb --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.m @@ -0,0 +1,33 @@ +// BwFileAccess.m + +#import "BwFileAccess.h" + + +@implementation BwFileAccess + +RCT_EXPORT_MODULE() + +RCT_EXPORT_METHOD(readFileContent:(NSString *)filePath + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) +{ + NSURL *fileURL = [NSURL URLWithString:filePath]; + + if ([fileURL startAccessingSecurityScopedResource]) { + NSError *error; + NSData *fileData = [NSData dataWithContentsOfURL:fileURL options:0 error:&error]; + + if (fileData) { + NSString *fileContent = [[NSString alloc] initWithData:fileData encoding:NSUTF8StringEncoding]; + resolve(fileContent); + } else { + reject(@"READ_ERROR", @"Failed to read file", error); + } + + [fileURL stopAccessingSecurityScopedResource]; + } else { + reject(@"ACCESS_ERROR", @"Failed to access security scoped resource", nil); + } +} + +@end diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj new file mode 100644 index 00000000000..f1edc9aff36 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcodeproj/project.pbxproj @@ -0,0 +1,281 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 46; + objects = { + +/* Begin PBXCopyFilesBuildPhase section */ + 58B511D91A9E6C8500147676 /* CopyFiles */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "include/$(PRODUCT_NAME)"; + dstSubfolderSpec = 16; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 134814201AA4EA6300B7C361 /* libBwFileAccess.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libBwFileAccess.a; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 58B511D81A9E6C8500147676 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 134814211AA4EA7D00B7C361 /* Products */ = { + isa = PBXGroup; + children = ( + 134814201AA4EA6300B7C361 /* libBwFileAccess.a */, + ); + name = Products; + sourceTree = ""; + }; + 58B511D21A9E6C8500147676 = { + isa = PBXGroup; + children = ( + 134814211AA4EA7D00B7C361 /* Products */, + ); + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 58B511DA1A9E6C8500147676 /* BwFileAccess */ = { + isa = PBXNativeTarget; + buildConfigurationList = 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "BwFileAccess" */; + buildPhases = ( + 58B511D71A9E6C8500147676 /* Sources */, + 58B511D81A9E6C8500147676 /* Frameworks */, + 58B511D91A9E6C8500147676 /* CopyFiles */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = BwFileAccess; + productName = RCTDataManager; + productReference = 134814201AA4EA6300B7C361 /* libBwFileAccess.a */; + productType = "com.apple.product-type.library.static"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 58B511D31A9E6C8500147676 /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 0920; + ORGANIZATIONNAME = Facebook; + TargetAttributes = { + 58B511DA1A9E6C8500147676 = { + CreatedOnToolsVersion = 6.1.1; + }; + }; + }; + buildConfigurationList = 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "BwFileAccess" */; + compatibilityVersion = "Xcode 3.2"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 58B511D21A9E6C8500147676; + productRefGroup = 58B511D21A9E6C8500147676; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 58B511DA1A9E6C8500147676 /* BwFileAccess */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 58B511D71A9E6C8500147676 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 58B511ED1A9E6C8500147676 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_SYMBOLS_PRIVATE_EXTERN = NO; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + }; + name = Debug; + }; + 58B511EE1A9E6C8500147676 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = YES; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.1; + LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)"; + LIBRARY_SEARCH_PATHS = ( + "\"$(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)\"", + "\"$(TOOLCHAIN_DIR)/usr/lib/swift-5.0/$(PLATFORM_NAME)\"", + "\"$(inherited)\"", + ); + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 58B511F01A9E6C8500147676 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, + "$(SRCROOT)/../../../React/**", + "$(SRCROOT)/../../react-native/React/**", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = BwFileAccess; + SKIP_INSTALL = YES; + }; + name = Debug; + }; + 58B511F11A9E6C8500147676 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + HEADER_SEARCH_PATHS = ( + "$(inherited)", + /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/include, + "$(SRCROOT)/../../../React/**", + "$(SRCROOT)/../../react-native/React/**", + ); + LIBRARY_SEARCH_PATHS = "$(inherited)"; + OTHER_LDFLAGS = "-ObjC"; + PRODUCT_NAME = BwFileAccess; + SKIP_INSTALL = YES; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 58B511D61A9E6C8500147676 /* Build configuration list for PBXProject "BwFileAccess" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58B511ED1A9E6C8500147676 /* Debug */, + 58B511EE1A9E6C8500147676 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 58B511EF1A9E6C8500147676 /* Build configuration list for PBXNativeTarget "BwFileAccess" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 58B511F01A9E6C8500147676 /* Debug */, + 58B511F11A9E6C8500147676 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 58B511D31A9E6C8500147676 /* Project object */; +} diff --git a/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata new file mode 100644 index 00000000000..a232186952a --- /dev/null +++ b/blue_modules/react-native-bw-file-access/ios/BwFileAccess.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/blue_modules/react-native-bw-file-access/package.json b/blue_modules/react-native-bw-file-access/package.json new file mode 100644 index 00000000000..965bd35bef6 --- /dev/null +++ b/blue_modules/react-native-bw-file-access/package.json @@ -0,0 +1,36 @@ +{ + "name": "react-native-bw-file-access", + "title": "React Native Bw File Access", + "version": "1.0.0", + "description": "TODO", + "main": "index.ts", + "homepage": "https://github.com/setavenger/react-native-bw-file-access", + "files": [ + "README.md", + "android", + "index.ts", + "ios", + "react-native-bw-file-access.podspec" + ], + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/setavenger/react-native-bw-file-access.git", + "baseUrl": "https://github.com/setavenger/react-native-bw-file-access" + }, + "keywords": [ + "react-native" + ], + "author": { + "name": "Setor Blagogee" + }, + "license": "MIT", + "licenseFilename": "LICENSE", + "readmeFilename": "README.md", + "peerDependencies": { + "react": ">=16.8.1", + "react-native": ">=0.60.0-rc.0 <1.0.x" + } +} diff --git a/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec b/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec new file mode 100644 index 00000000000..84ffdcbc0df --- /dev/null +++ b/blue_modules/react-native-bw-file-access/react-native-bw-file-access.podspec @@ -0,0 +1,19 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "react-native-bw-file-access" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => "10.0" } + s.source = { :git => "https://github.com/setavenger/react-native-bw-file-access.git", :tag => "#{s.version}" } + + s.source_files = "ios/**/*.{h,m,mm}" + + s.dependency "React-Core" +end \ No newline at end of file diff --git a/blue_modules/showPopupMenu.android.ts b/blue_modules/showPopupMenu.android.ts deleted file mode 100644 index 114615f1165..00000000000 --- a/blue_modules/showPopupMenu.android.ts +++ /dev/null @@ -1,32 +0,0 @@ -// @ts-ignore: Ignore -import type { Element } from 'react'; -import { Text, TouchableNativeFeedback, TouchableWithoutFeedback, View, findNodeHandle, UIManager } from 'react-native'; - -type PopupMenuItem = { id?: any; label: string }; -type OnPopupMenuItemSelect = (selectedPopupMenuItem: PopupMenuItem) => void; -type PopupAnchor = Element; -type PopupMenuOptions = { onCancel?: () => void }; - -function showPopupMenu( - items: PopupMenuItem[], - onSelect: OnPopupMenuItemSelect, - anchor: PopupAnchor, - { onCancel }: PopupMenuOptions = {}, -): void { - UIManager.showPopupMenu( - // @ts-ignore: Ignore - findNodeHandle(anchor), - items.map(item => item.label), - function () { - if (onCancel) onCancel(); - }, - function (eventName: 'dismissed' | 'itemSelected', selectedIndex?: number) { - // @ts-ignore: Ignore - if (eventName === 'itemSelected') onSelect(items[selectedIndex]); - else onCancel && onCancel(); - }, - ); -} - -export type { PopupMenuItem, OnPopupMenuItemSelect, PopupMenuOptions }; -export default showPopupMenu; diff --git a/blue_modules/sizeClass.ts b/blue_modules/sizeClass.ts new file mode 100644 index 00000000000..e5ca0a53ac5 --- /dev/null +++ b/blue_modules/sizeClass.ts @@ -0,0 +1,219 @@ +import { AppState, AppStateStatus, Dimensions, NativeEventEmitter, NativeModules, Platform } from 'react-native'; +import { useEffect, useState } from 'react'; +import { isDesktop } from './environment'; + +type NativeSizeClassPayload = { + horizontal?: number; + vertical?: number; + sizeClass?: number; + orientation?: string; + isLargeScreen?: boolean; +}; + +const sizeClassNativeModule = NativeModules.SizeClassEmitter as + | { + getCurrentSizeClass?: () => Promise; + addListener: (eventType: string) => any; + removeListeners: (count: number) => void; + } + | undefined; + +const sizeClassNativeEmitter = sizeClassNativeModule ? new NativeEventEmitter(sizeClassNativeModule) : null; +const NATIVE_EVENT_NAME = 'sizeClassDidChange'; + +// Size class definitions following iOS conventions +export enum SizeClass { + Compact, // Small size (iPhone width or height in landscape) + Regular, // Standard size (iPad, or iPhone height in portrait) + Large, // Additional size for larger screens (not in iOS, but useful for our app) +} + +// Interface for the result of getSizeClass +export interface SizeClassInfo { + // Size classes + horizontalSizeClass: SizeClass; + verticalSizeClass: SizeClass; + + // Overall size class (derived from horizontal and vertical) + sizeClass: SizeClass; + + // Orientation + orientation: 'portrait' | 'landscape'; + + // Helper properties + isCompact: boolean; + isLarge: boolean; + + // Legacy support + isLargeScreen: boolean; +} + +const normalizeOrientation = (orientation?: string): 'portrait' | 'landscape' => (orientation === 'landscape' ? 'landscape' : 'portrait'); + +const coerceSizeClassValue = (value?: number): SizeClass => { + if (value === SizeClass.Compact || value === SizeClass.Regular || value === SizeClass.Large) { + return value; + } + return SizeClass.Regular; +}; + +const calculateFromDimensions = (): SizeClassInfo => { + const { width, height } = Dimensions.get('window'); + const isLandscape = width > height; + const orientation = isLandscape ? 'landscape' : 'portrait'; + + const horizontalSizeClass = + Platform.OS === 'ios' && Platform.isPad + ? SizeClass.Regular + : isDesktop + ? SizeClass.Large + : isLandscape && width >= 667 + ? SizeClass.Regular + : SizeClass.Compact; + + const verticalSizeClass = + Platform.OS === 'ios' && Platform.isPad + ? SizeClass.Regular + : isDesktop + ? SizeClass.Large + : isLandscape + ? SizeClass.Compact + : SizeClass.Regular; + + const sizeClass = coerceSizeClassValue(horizontalSizeClass); + const isLargeScreen = sizeClass === SizeClass.Large; + + return { + horizontalSizeClass, + verticalSizeClass, + sizeClass, + orientation, + isCompact: sizeClass === SizeClass.Compact, + isLarge: sizeClass === SizeClass.Large, + isLargeScreen, + }; +}; + +const normalizeNativePayload = (payload?: NativeSizeClassPayload | null): SizeClassInfo | null => { + if (!payload) { + return null; + } + + const horizontalSizeClass = coerceSizeClassValue(payload.horizontal); + const verticalSizeClass = coerceSizeClassValue(payload.vertical); + const sizeClass = coerceSizeClassValue(payload.sizeClass); + + const isLargeScreen = payload.isLargeScreen ?? sizeClass === SizeClass.Large; + const orientation = normalizeOrientation(payload.orientation); + + return { + horizontalSizeClass, + verticalSizeClass, + sizeClass, + orientation, + isCompact: sizeClass === SizeClass.Compact, + isLarge: sizeClass === SizeClass.Large, + isLargeScreen, + }; +}; + +let cachedSizeClassInfo: SizeClassInfo = calculateFromDimensions(); +let nativeInitRequested = false; + +const fetchNativeSizeClass = async (): Promise => { + if (!sizeClassNativeModule?.getCurrentSizeClass) { + return null; + } + + try { + const result = await sizeClassNativeModule.getCurrentSizeClass(); + return normalizeNativePayload(result); + } catch (error) { + console.debug('[SizeClass] Failed to read native size class', error); + return null; + } +}; + +/** + * Get current size class information. + */ +export function getSizeClass(): SizeClassInfo { + if (!sizeClassNativeModule) { + cachedSizeClassInfo = calculateFromDimensions(); + } else if (!nativeInitRequested) { + nativeInitRequested = true; + fetchNativeSizeClass().then(nativeInfo => { + if (nativeInfo) { + cachedSizeClassInfo = nativeInfo; + } + }); + } + + return cachedSizeClassInfo; +} + +/** + * React hook to use size classes in components + */ +export function useSizeClass(): SizeClassInfo { + const [sizeClassInfo, setSizeClassInfo] = useState(cachedSizeClassInfo); + + useEffect(() => { + let isMounted = true; + + const applySizeClass = (info: SizeClassInfo) => { + if (!isMounted) return; + cachedSizeClassInfo = info; + setSizeClassInfo(info); + console.debug( + `[SizeClass] Updated:`, + `horizontal=${SizeClass[info.horizontalSizeClass]}`, + `vertical=${SizeClass[info.verticalSizeClass]}`, + `orientation=${info.orientation}`, + `isLargeScreen=${info.isLargeScreen}`, + ); + }; + + const updateFromDimensions = () => { + const calculated = calculateFromDimensions(); + applySizeClass(calculated); + }; + + const requestNativeUpdate = async () => { + const nativeInfo = await fetchNativeSizeClass(); + if (nativeInfo) { + applySizeClass(nativeInfo); + } + }; + + const dimensionSubscription = Dimensions.addEventListener('change', () => { + updateFromDimensions(); + requestNativeUpdate(); + }); + + const appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { + if (nextAppState === 'active') { + requestNativeUpdate(); + } + }); + + const nativeSubscription = sizeClassNativeEmitter?.addListener(NATIVE_EVENT_NAME, (payload: NativeSizeClassPayload) => { + const normalized = normalizeNativePayload(payload); + if (normalized) { + applySizeClass(normalized); + } + }); + + // Kick off an initial native fetch to override the heuristic when available. + requestNativeUpdate(); + + return () => { + isMounted = false; + dimensionSubscription.remove(); + appStateSubscription.remove(); + nativeSubscription?.remove(); + }; + }, []); + + return sizeClassInfo; +} diff --git a/blue_modules/start-and-decrypt.ts b/blue_modules/start-and-decrypt.ts new file mode 100644 index 00000000000..d3771679402 --- /dev/null +++ b/blue_modules/start-and-decrypt.ts @@ -0,0 +1,75 @@ +import { Platform } from 'react-native'; + +import { BlueApp as BlueAppClass } from '../class/blue-app'; +import prompt from '../helpers/prompt'; +import { showKeychainWipeAlert } from '../hooks/useBiometrics'; +import loc from '../loc'; + +const BlueApp = BlueAppClass.getInstance(); +// If attempt reaches 10, a wipe keychain option will be provided to the user. +let unlockAttempt = 0; + +type PasswordPromptCallback = () => Promise; + +export const startAndDecrypt = async (retry?: boolean, passwordPrompt?: PasswordPromptCallback): Promise => { + // If wallets are already loaded, no need to migrate, decrypt, or load from disk. + if (BlueApp.getWallets().length > 0) { + return true; + } + await BlueApp.migrateKeys(); + let password: undefined | string; + if (await BlueApp.storageIsEncrypted()) { + if (passwordPrompt) { + password = await passwordPrompt(); + } else { + do { + password = await prompt((retry && loc._.bad_password) || loc._.enter_password, loc._.storage_is_encrypted, { cancelable: false }); + } while (!password); + } + } + let success = false; + let wasException = false; + try { + success = await BlueApp.loadFromDisk(password); + } catch (error) { + // in case of exception reading from keystore, lets retry instead of assuming there is no storage and + // proceeding with no wallets + console.warn('exception loading from disk:', error); + wasException = true; + } + + if (wasException) { + // retrying, but only once + try { + await new Promise(resolve => setTimeout(resolve, 3000)); // sleep + success = await BlueApp.loadFromDisk(password); + } catch (error) { + console.warn('second exception loading from disk:', error); + } + } + + if (success) { + console.log('loaded from disk'); + return true; + } + + if (password) { + // we had password and yet could not load/decrypt + unlockAttempt++; + if (unlockAttempt < 10 || Platform.OS !== 'ios') { + // Return false to indicate wrong password, let UI show error and retry + return false; + } else { + unlockAttempt = 0; + showKeychainWipeAlert(); + // We want to return false to let the UnlockWith screen that it is NOT ok to proceed. + return false; + } + } else { + unlockAttempt = 0; + // Return true because there was no wallet data in keychain. Proceed. + return true; + } +}; + +export default BlueApp; diff --git a/blue_modules/storage-context.js b/blue_modules/storage-context.js deleted file mode 100644 index 01622923af2..00000000000 --- a/blue_modules/storage-context.js +++ /dev/null @@ -1,289 +0,0 @@ -import React, { createContext, useEffect, useState } from 'react'; -import { Alert } from 'react-native'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import { useAsyncStorage } from '@react-native-async-storage/async-storage'; -import { FiatUnit } from '../models/fiatUnit'; -import Notifications from '../blue_modules/notifications'; -import loc, { STORAGE_KEY as LOC_STORAGE_KEY } from '../loc'; -import { LegacyWallet, WatchOnlyWallet } from '../class'; -import { isTorDaemonDisabled, setIsTorDaemonDisabled } from './environment'; -import alert from '../components/Alert'; -const BlueApp = require('../BlueApp'); -const BlueElectrum = require('./BlueElectrum'); -const currency = require('../blue_modules/currency'); -const A = require('../blue_modules/analytics'); - -const _lastTimeTriedToRefetchWallet = {}; // hashmap of timestamps we _started_ refetching some wallet - -export const WalletTransactionsStatus = { NONE: false, ALL: true }; -export const BlueStorageContext = createContext(); -export const BlueStorageProvider = ({ children }) => { - const [wallets, setWallets] = useState([]); - const [selectedWallet, setSelectedWallet] = useState(''); - const [walletTransactionUpdateStatus, setWalletTransactionUpdateStatus] = useState(WalletTransactionsStatus.NONE); - const [walletsInitialized, setWalletsInitialized] = useState(false); - const [preferredFiatCurrency, _setPreferredFiatCurrency] = useState(FiatUnit.USD); - const [language, _setLanguage] = useState(); - const getPreferredCurrencyAsyncStorage = useAsyncStorage(currency.PREFERRED_CURRENCY).getItem; - const getLanguageAsyncStorage = useAsyncStorage(LOC_STORAGE_KEY).getItem; - const [isHandOffUseEnabled, setIsHandOffUseEnabled] = useState(false); - const [isElectrumDisabled, setIsElectrumDisabled] = useState(true); - const [isTorDisabled, setIsTorDisabled] = useState(false); - const [isPrivacyBlurEnabled, setIsPrivacyBlurEnabled] = useState(true); - - useEffect(() => { - BlueElectrum.isDisabled().then(setIsElectrumDisabled); - isTorDaemonDisabled().then(setIsTorDisabled); - }, []); - - useEffect(() => { - console.log(`Privacy blur: ${isPrivacyBlurEnabled}`); - if (!isPrivacyBlurEnabled) { - alert('Privacy blur has been disabled.'); - } - }, [isPrivacyBlurEnabled]); - - useEffect(() => { - setIsTorDaemonDisabled(isTorDisabled); - }, [isTorDisabled]); - - const setIsHandOffUseEnabledAsyncStorage = value => { - setIsHandOffUseEnabled(value); - return BlueApp.setIsHandoffEnabled(value); - }; - - const saveToDisk = async (force = false) => { - if (BlueApp.getWallets().length === 0 && !force) { - console.log('not saving empty wallets array'); - return; - } - BlueApp.tx_metadata = txMetadata; - await BlueApp.saveToDisk(); - setWallets([...BlueApp.getWallets()]); - txMetadata = BlueApp.tx_metadata; - }; - - useEffect(() => { - setWallets(BlueApp.getWallets()); - }, []); - - useEffect(() => { - (async () => { - try { - const enabledHandoff = await BlueApp.isHandoffEnabled(); - setIsHandOffUseEnabled(!!enabledHandoff); - } catch (_e) { - setIsHandOffUseEnabledAsyncStorage(false); - setIsHandOffUseEnabled(false); - } - })(); - }, []); - - const getPreferredCurrency = async () => { - const item = await getPreferredCurrencyAsyncStorage(); - _setPreferredFiatCurrency(item); - }; - - const setPreferredFiatCurrency = () => { - getPreferredCurrency(); - }; - - const getLanguage = async () => { - const item = await getLanguageAsyncStorage(); - _setLanguage(item); - }; - - const setLanguage = () => { - getLanguage(); - }; - - useEffect(() => { - getPreferredCurrency(); - getLanguageAsyncStorage(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const resetWallets = () => { - setWallets(BlueApp.getWallets()); - }; - - const setWalletsWithNewOrder = wlts => { - BlueApp.wallets = wlts; - saveToDisk(); - }; - - const refreshAllWalletTransactions = async (lastSnappedTo, showUpdateStatusIndicator = true) => { - let noErr = true; - try { - if (showUpdateStatusIndicator) { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL); - } - await BlueElectrum.waitTillConnected(); - const paymentCodesStart = Date.now(); - await fetchSenderPaymentCodes(lastSnappedTo); - const paymentCodesEnd = Date.now(); - console.log('fetch payment codes took', (paymentCodesEnd - paymentCodesStart) / 1000, 'sec'); - const balanceStart = +new Date(); - await fetchWalletBalances(lastSnappedTo); - const balanceEnd = +new Date(); - console.log('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); - const start = +new Date(); - await fetchWalletTransactions(lastSnappedTo); - const end = +new Date(); - console.log('fetch tx took', (end - start) / 1000, 'sec'); - } catch (err) { - noErr = false; - console.warn(err); - } finally { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); - } - if (noErr) await saveToDisk(); // caching - }; - - const fetchAndSaveWalletTransactions = async walletID => { - const index = wallets.findIndex(wallet => wallet.getID() === walletID); - let noErr = true; - try { - // 5sec debounce: - setWalletTransactionUpdateStatus(walletID); - if (+new Date() - _lastTimeTriedToRefetchWallet[walletID] < 5000) { - console.log('re-fetch wallet happens too fast; NOP'); - return; - } - _lastTimeTriedToRefetchWallet[walletID] = +new Date(); - - await BlueElectrum.waitTillConnected(); - const balanceStart = +new Date(); - await fetchWalletBalances(index); - const balanceEnd = +new Date(); - console.log('fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); - const start = +new Date(); - await fetchWalletTransactions(index); - const end = +new Date(); - console.log('fetch tx took', (end - start) / 1000, 'sec'); - } catch (err) { - noErr = false; - console.warn(err); - } finally { - setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); - } - if (noErr) await saveToDisk(); // caching - }; - - const addWallet = wallet => { - BlueApp.wallets.push(wallet); - setWallets([...BlueApp.getWallets()]); - }; - - const deleteWallet = wallet => { - BlueApp.deleteWallet(wallet); - setWallets([...BlueApp.getWallets()]); - }; - - const addAndSaveWallet = async w => { - if (wallets.some(i => i.getID() === w.getID())) { - ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); - Alert.alert('', 'This wallet has been previously imported.'); - return; - } - const emptyWalletLabel = new LegacyWallet().getLabel(); - ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); - if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable); - w.setUserHasSavedExport(true); - addWallet(w); - await saveToDisk(); - A(A.ENUM.CREATED_WALLET); - Alert.alert('', w.type === WatchOnlyWallet.type ? loc.wallets.import_success_watchonly : loc.wallets.import_success); - Notifications.majorTomToGroundControl(w.getAllExternalAddresses(), [], []); - // start balance fetching at the background - await w.fetchBalance(); - setWallets([...BlueApp.getWallets()]); - }; - - let txMetadata = BlueApp.tx_metadata || {}; - const getTransactions = BlueApp.getTransactions; - const isAdvancedModeEnabled = BlueApp.isAdvancedModeEnabled; - - const fetchSenderPaymentCodes = BlueApp.fetchSenderPaymentCodes; - const fetchWalletBalances = BlueApp.fetchWalletBalances; - const fetchWalletTransactions = BlueApp.fetchWalletTransactions; - const getBalance = BlueApp.getBalance; - const isStorageEncrypted = BlueApp.storageIsEncrypted; - const startAndDecrypt = BlueApp.startAndDecrypt; - const encryptStorage = BlueApp.encryptStorage; - const sleep = BlueApp.sleep; - const setHodlHodlApiKey = BlueApp.setHodlHodlApiKey; - const getHodlHodlApiKey = BlueApp.getHodlHodlApiKey; - const createFakeStorage = BlueApp.createFakeStorage; - const decryptStorage = BlueApp.decryptStorage; - const isPasswordInUse = BlueApp.isPasswordInUse; - const cachedPassword = BlueApp.cachedPassword; - const setIsAdvancedModeEnabled = BlueApp.setIsAdvancedModeEnabled; - const getHodlHodlSignatureKey = BlueApp.getHodlHodlSignatureKey; - const addHodlHodlContract = BlueApp.addHodlHodlContract; - const getHodlHodlContracts = BlueApp.getHodlHodlContracts; - const setDoNotTrack = BlueApp.setDoNotTrack; - const isDoNotTrackEnabled = BlueApp.isDoNotTrackEnabled; - const getItem = BlueApp.getItem; - const setItem = BlueApp.setItem; - - return ( - - {children} - - ); -}; diff --git a/blue_modules/tls.js b/blue_modules/tls.js deleted file mode 100644 index 1684e71c4db..00000000000 --- a/blue_modules/tls.js +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @fileOverview adapter for ReactNative TCP module - * This module mimics the nodejs tls api and is intended to work in RN environment. - * @see https://github.com/Rapsssito/react-native-tcp-socket - */ - -import TcpSocket from 'react-native-tcp-socket'; - -/** - * Constructor function. Mimicking nodejs/tls api - * - * @constructor - */ -function connect(config, callback) { - const client = TcpSocket.createConnection( - { - port: config.port, - host: config.host, - tls: true, - tlsCheckValidity: config.rejectUnauthorized, - }, - callback, - ); - - // defaults: - this._noDelay = true; - - // functions not supported by RN module, yet: - client.setTimeout = () => {}; - client.setEncoding = () => {}; - client.setKeepAlive = () => {}; - - // we will save `noDelay` and proxy it to socket object when its actually created and connected: - const realSetNoDelay = client.setNoDelay; // reference to real setter - client.setNoDelay = noDelay => { - this._noDelay = noDelay; - }; - - client.on('connect', () => { - realSetNoDelay.apply(client, [this._noDelay]); - }); - - return client; -} - -module.exports.connect = connect; diff --git a/blue_modules/torrific.js b/blue_modules/torrific.js deleted file mode 100644 index bde1b4cf8c6..00000000000 --- a/blue_modules/torrific.js +++ /dev/null @@ -1,290 +0,0 @@ -import Tor from 'react-native-tor'; -const tor = Tor({ - bootstrapTimeoutMs: 35000, - numberConcurrentRequests: 1, -}); - -/** - * TOR wrapper mimicking Frisbee interface - */ -class Torsbee { - baseURI = ''; - - static _testConn; - static _resolveReference; - static _rejectReference; - - constructor(opts) { - opts = opts || {}; - this.baseURI = opts.baseURI || this.baseURI; - } - - async get(path, options) { - console.log('TOR: starting...'); - const socksProxy = await tor.startIfNotStarted(); - console.log('TOR: started', await tor.getDaemonStatus(), 'on local port', socksProxy); - if (path.startsWith('/') && this.baseURI.endsWith('/')) { - // oy vey, duplicate slashes - path = path.substr(1); - } - - const response = {}; - try { - const uri = this.baseURI + path; - console.log('TOR: requesting', uri); - const torResponse = await tor.get(uri, options?.headers || {}, true); - response.originalResponse = torResponse; - - if (options?.headers['Content-Type'] === 'application/json' && torResponse.json) { - response.body = torResponse.json; - } else { - response.body = Buffer.from(torResponse.b64Data, 'base64').toString(); - } - } catch (error) { - response.err = error; - console.warn(error); - } - - return response; - } - - async post(path, options) { - console.log('TOR: starting...'); - const socksProxy = await tor.startIfNotStarted(); - console.log('TOR: started', await tor.getDaemonStatus(), 'on local port', socksProxy); - if (path.startsWith('/') && this.baseURI.endsWith('/')) { - // oy vey, duplicate slashes - path = path.substr(1); - } - - const uri = this.baseURI + path; - console.log('TOR: posting to', uri); - - const response = {}; - try { - const torResponse = await tor.post(uri, JSON.stringify(options?.body || {}), options?.headers || {}, true); - response.originalResponse = torResponse; - - if (options?.headers['Content-Type'] === 'application/json' && torResponse.json) { - response.body = torResponse.json; - } else { - response.body = Buffer.from(torResponse.b64Data, 'base64').toString(); - } - } catch (error) { - response.err = error; - console.warn(error); - } - - return response; - } - - testSocket() { - return new Promise((resolve, reject) => { - this.constructor._resolveReference = resolve; - this.constructor._rejectReference = reject; - (async () => { - console.log('testSocket...'); - try { - if (!this.constructor._testConn) { - // no test conenctino exists, creating it... - await tor.startIfNotStarted(); - const target = 'explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion:110'; - this.constructor._testConn = await tor.createTcpConnection({ target }, (data, err) => { - if (err) { - return this.constructor._rejectReference(new Error(err)); - } - const json = JSON.parse(data); - if (!json || typeof json.result === 'undefined') - return this.constructor._rejectReference(new Error('Unexpected response from TOR socket: ' + JSON.stringify(json))); - - // conn.close(); - // instead of closing connect, we will actualy re-cyce existing test connection as we - // saved it into `this.constructor.testConn` - this.constructor._resolveReference(); - }); - - await this.constructor._testConn.write( - `{ "id": 1, "method": "blockchain.scripthash.get_balance", "params": ["716decbe1660861c3d93906cb1d98ee68b154fd4d23aed9783859c1271b52a9c"] }\n`, - ); - } else { - // test connectino exists, so we are reusing it - await this.constructor._testConn.write( - `{ "id": 1, "method": "blockchain.scripthash.get_balance", "params": ["716decbe1660861c3d93906cb1d98ee68b154fd4d23aed9783859c1271b52a9c"] }\n`, - ); - } - } catch (error) { - this.constructor._rejectReference(error); - } - })(); - }); - } -} - -/** - * Wrapper for react-native-tor mimicking Socket class from NET package - */ -class TorSocket { - constructor() { - this._socket = false; - this._listeners = {}; - } - - setTimeout() {} - - setEncoding() {} - - setKeepAlive() {} - - setNoDelay() {} - - on(event, listener) { - this._listeners[event] = this._listeners[event] || []; - this._listeners[event].push(listener); - } - - removeListener(event, listener) { - this._listeners[event] = this._listeners[event] || []; - const newListeners = []; - - let found = false; - for (const savedListener of this._listeners[event]) { - // eslint-disable-next-line eqeqeq - if (savedListener == listener) { - // found our listener - found = true; - // we just skip it - } else { - // other listeners should go back to original array - newListeners.push(savedListener); - } - } - - if (found) { - this._listeners[event] = newListeners; - } else { - // something went wrong, lets just cleanup all listeners - this._listeners[event] = []; - } - } - - connect(port, host, callback) { - console.log('connecting TOR socket...', host, port); - (async () => { - console.log('starting tor...'); - try { - await tor.startIfNotStarted(); - } catch (e) { - console.warn('Could not bootstrap TOR', e); - await tor.stopIfRunning(); - this._passOnEvent('error', 'Could not bootstrap TOR'); - return false; - } - console.log('started tor'); - const iWillConnectISwear = tor.createTcpConnection({ target: host + ':' + port, connectionTimeout: 15000 }, (data, err) => { - if (err) { - console.log('TOR socket onData error: ', err); - // this._passOnEvent('error', err); - return; - } - this._passOnEvent('data', data); - }); - - try { - this._socket = await Promise.race([iWillConnectISwear, new Promise(resolve => setTimeout(resolve, 21000))]); - } catch (e) {} - - if (!this._socket) { - console.log('connecting TOR socket failed'); // either sleep expired or connect threw an exception - await tor.stopIfRunning(); - this._passOnEvent('error', 'connecting TOR socket failed'); - return false; - } - - console.log('TOR socket connected:', host, port); - setTimeout(() => { - this._passOnEvent('connect', true); - callback(); - }, 1000); - })(); - } - - _passOnEvent(event, data) { - this._listeners[event] = this._listeners[event] || []; - for (const savedListener of this._listeners[event]) { - savedListener(data); - } - } - - emit(event, data) {} - - end() { - console.log('trying to close TOR socket'); - if (this._socket && this._socket.close) { - console.log('trying to close TOR socket SUCCESS'); - return this._socket.close(); - } - } - - destroy() {} - - write(data) { - if (this._socket && this._socket.write) { - try { - return this._socket.write(data); - } catch (error) { - console.log('this._socket.write() failed so we are issuing ERROR event', error); - this._passOnEvent('error', error); - } - } else { - console.log('TOR socket write error, socket not connected'); - this._passOnEvent('error', 'TOR socket not connected'); - } - } -} - -module.exports.getDaemonStatus = async () => { - try { - return await tor.getDaemonStatus(); - } catch (_) { - return false; - } -}; - -module.exports.stopIfRunning = async () => { - try { - Torsbee._testConn = false; - return await tor.stopIfRunning(); - } catch (_) { - return false; - } -}; - -module.exports.startIfNotStarted = async () => { - try { - return await tor.startIfNotStarted(); - } catch (_) { - return false; - } -}; - -module.exports.testSocket = async () => { - const c = new Torsbee(); - return c.testSocket(); -}; - -module.exports.testHttp = async () => { - const api = new Torsbee({ - baseURI: 'http://explorerzydxu5ecjrkwceayqybizmpjjznk5izmitf2modhcusuqlid.onion:80/', - }); - const torResponse = await api.get('/api/tx/a84dbcf0d2550f673dda9331eea7cab86b645fd6e12049755c4b47bd238adce9', { - headers: { - 'Content-Type': 'application/json', - }, - }); - const json = torResponse.body; - if (json.txid !== 'a84dbcf0d2550f673dda9331eea7cab86b645fd6e12049755c4b47bd238adce9') - throw new Error('TOR failure, got ' + JSON.stringify(torResponse)); -}; - -module.exports.Torsbee = Torsbee; -module.exports.Socket = TorSocket; diff --git a/blue_modules/transactionDisplayState.ts b/blue_modules/transactionDisplayState.ts new file mode 100644 index 00000000000..c51b6af47d5 --- /dev/null +++ b/blue_modules/transactionDisplayState.ts @@ -0,0 +1,41 @@ +// Display state for the transaction detail screen. +// +// On-chain rows (a real Bitcoin txid is present in `hash`) keep the existing +// confirmations-based logic. Ark/Lightning rows synthesized by +// LightningArkWallet.getTransactions() carry no on-chain `hash` and never a +// `confirmations` field, so their state is derived from row semantics instead. +// The off-chain branch mirrors the off-chain cases of +// components/TransactionListItem.tsx `listTitleKey` so the list row and the detail +// screen always agree. A `boarding-utxo-` row is a refill still awaiting +// settlement and is pending (matches TransactionListItem.isPendingRefill); a +// settled `boarding-` refill is a confirmed receive. Today only `bitcoind_tx` Ark +// rows reach the detail screen (swap rows route to LNDViewInvoice); the invoice +// cases are handled defensively. +export type TxDisplayState = 'pending' | 'sent' | 'received'; + +export function isOnChainTransaction(tx: any): boolean { + return typeof tx?.hash === 'string' && tx.hash.length > 0; +} + +export function resolveTxDisplayState(tx: any): TxDisplayState { + if (isOnChainTransaction(tx)) { + const confs = Number(tx?.confirmations); + const pending = Number.isFinite(confs) ? confs <= 0 : !tx?.confirmations; + if (pending) return 'pending'; + return Number(tx?.value) < 0 ? 'sent' : 'received'; + } + // A refill awaiting settlement (boarding UTXO not yet swept into a VTXO) is + // pending until it promotes to a settled `boarding-` refill — mirror + // TransactionListItem.isPendingRefill so the list row and detail screen agree. + if (typeof tx?.txid === 'string' && tx.txid.startsWith('boarding-utxo-')) return 'pending'; + // Off-chain Ark/Lightning row — never confirmations-based. + switch (tx?.type) { + case 'paid_invoice': + return 'sent'; + case 'user_invoice': + case 'payment_request': + return tx?.ispaid ? 'received' : 'pending'; + default: // settled refill (boarding-), native Ark legs (ark-), any other hash-less row + return Number(tx?.value) < 0 ? 'sent' : 'received'; + } +} diff --git a/blue_modules/uint8array-extras/index.d.ts b/blue_modules/uint8array-extras/index.d.ts new file mode 100644 index 00000000000..353ea95ec57 --- /dev/null +++ b/blue_modules/uint8array-extras/index.d.ts @@ -0,0 +1,311 @@ +export type TypedArray = Int8Array | Uint8Array | Uint8ClampedArray | Int16Array | Uint16Array | Int32Array | Uint32Array | Float32Array | Float64Array | BigInt64Array | BigUint64Array; + +/** +Check if the given value is an instance of `Uint8Array`. + +Replacement for [`Buffer.isBuffer()`](https://nodejs.org/api/buffer.html#static-method-bufferisbufferobj). + +@example +``` +import {isUint8Array} from 'uint8array-extras'; + +console.log(isUint8Array(new Uint8Array())); +//=> true + +console.log(isUint8Array(Buffer.from('x'))); +//=> true + +console.log(isUint8Array(new ArrayBuffer(10))); +//=> false +``` +*/ +export function isUint8Array(value: unknown): value is Uint8Array; + +/** +Throw a `TypeError` if the given value is not an instance of `Uint8Array`. + +@example +``` +import {assertUint8Array} from 'uint8array-extras'; + +try { + assertUint8Array(new ArrayBuffer(10)); // Throws a TypeError +} catch (error) { + console.error(error.message); +} +``` +*/ +export function assertUint8Array(value: unknown): asserts value is Uint8Array; + +/** +Convert a value to a `Uint8Array` without copying its data. + +This can be useful for converting a `Buffer` to a pure `Uint8Array`. `Buffer` is already an `Uint8Array` subclass, but [`Buffer` alters some behavior](https://sindresorhus.com/blog/goodbye-nodejs-buffer), so it can be useful to cast it to a pure `Uint8Array` before returning it. + +Tip: If you want a copy, just call `.slice()` on the return value. +*/ +export function toUint8Array(value: TypedArray | ArrayBuffer | DataView): Uint8Array; + +/** +Concatenate the given arrays into a new array. + +If `arrays` is empty, it will return a zero-sized `Uint8Array`. + +If `totalLength` is not specified, it is calculated from summing the lengths of the given arrays. + +Replacement for [`Buffer.concat()`](https://nodejs.org/api/buffer.html#static-method-bufferconcatlist-totallength). + +@example +``` +import {concatUint8Arrays} from 'uint8array-extras'; + +const a = new Uint8Array([1, 2, 3]); +const b = new Uint8Array([4, 5, 6]); + +console.log(concatUint8Arrays([a, b])); +//=> Uint8Array [1, 2, 3, 4, 5, 6] +``` +*/ +export function concatUint8Arrays(arrays: Uint8Array[], totalLength?: number): Uint8Array; + +/** +Check if two arrays are identical by verifying that they contain the same bytes in the same sequence. + +Replacement for [`Buffer#equals()`](https://nodejs.org/api/buffer.html#bufequalsotherbuffer). + +@example +``` +import {areUint8ArraysEqual} from 'uint8array-extras'; + +const a = new Uint8Array([1, 2, 3]); +const b = new Uint8Array([1, 2, 3]); +const c = new Uint8Array([4, 5, 6]); + +console.log(areUint8ArraysEqual(a, b)); +//=> true + +console.log(areUint8ArraysEqual(a, c)); +//=> false +``` +*/ +export function areUint8ArraysEqual(a: Uint8Array, b: Uint8Array): boolean; + +/** +Compare two arrays and indicate their relative order or equality. Useful for sorting. + +Replacement for [`Buffer.compare()`](https://nodejs.org/api/buffer.html#static-method-buffercomparebuf1-buf2). + +@example +``` +import {compareUint8Arrays} from 'uint8array-extras'; + +const array1 = new Uint8Array([1, 2, 3]); +const array2 = new Uint8Array([4, 5, 6]); +const array3 = new Uint8Array([7, 8, 9]); + +[array3, array1, array2].sort(compareUint8Arrays); +//=> [[1, 2, 3], [4, 5, 6], [7, 8, 9]] +``` +*/ +export function compareUint8Arrays(a: Uint8Array, b: Uint8Array): 0 | 1 | -1; + +/** +Convert a `Uint8Array` to a string. + +@param encoding - The [encoding](https://developer.mozilla.org/en-US/docs/Web/API/Encoding_API/Encodings) to convert from. Default: `'utf8'` + +Replacement for [`Buffer#toString()`](https://nodejs.org/api/buffer.html#buftostringencoding-start-end). For the `encoding` parameter, `latin1` should be used instead of `binary` and `utf-16le` instead of `utf16le`. + +@example +``` +import {uint8ArrayToString} from 'uint8array-extras'; + +const byteArray = new Uint8Array([72, 101, 108, 108, 111]); +console.log(uint8ArrayToString(byteArray)); +//=> 'Hello' + +const zh = new Uint8Array([167, 65, 166, 110]); +console.log(uint8ArrayToString(zh, 'big5')); +//=> '你好' + +const ja = new Uint8Array([130, 177, 130, 241, 130, 201, 130, 191, 130, 205]); +console.log(uint8ArrayToString(ja, 'shift-jis')); +//=> 'こんにちは' +``` +*/ +// export function uint8ArrayToString(array: Uint8Array | ArrayBuffer, encoding?: string): string; + +/** +Convert a string to a `Uint8Array` (using UTF-8 encoding). + +Replacement for [`Buffer.from('Hello')`](https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding). + +@example +``` +import {stringToUint8Array} from 'uint8array-extras'; + +console.log(stringToUint8Array('Hello')); +//=> Uint8Array [72, 101, 108, 108, 111] +``` +*/ +export function stringToUint8Array(string: string): Uint8Array; + +/** +Convert a `Uint8Array` to a Base64-encoded string. + +Specify `{urlSafe: true}` to get a [Base64URL](https://base64.guru/standards/base64url)-encoded string. + +Replacement for [`Buffer#toString('base64')`](https://nodejs.org/api/buffer.html#buftostringencoding-start-end). + +@example +``` +import {uint8ArrayToBase64} from 'uint8array-extras'; + +const byteArray = new Uint8Array([72, 101, 108, 108, 111]); + +console.log(uint8ArrayToBase64(byteArray)); +//=> 'SGVsbG8=' +``` +*/ +export function uint8ArrayToBase64(array: Uint8Array, options?: { urlSafe: boolean }): string; + +/** +Convert a Base64-encoded or [Base64URL](https://base64.guru/standards/base64url)-encoded string to a `Uint8Array`. + +Replacement for [`Buffer.from('SGVsbG8=', 'base64')`](https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding). + +@example +``` +import {base64ToUint8Array} from 'uint8array-extras'; + +console.log(base64ToUint8Array('SGVsbG8=')); +//=> Uint8Array [72, 101, 108, 108, 111] +``` +*/ +export function base64ToUint8Array(string: string): Uint8Array; + +/** +Encode a string to Base64-encoded string. + +Specify `{urlSafe: true}` to get a [Base64URL](https://base64.guru/standards/base64url)-encoded string. + +Replacement for `Buffer.from('Hello').toString('base64')` and [`btoa()`](https://developer.mozilla.org/en-US/docs/Web/API/btoa). + +@example +``` +import {stringToBase64} from 'uint8array-extras'; + +console.log(stringToBase64('Hello')); +//=> 'SGVsbG8=' +``` +*/ +export function stringToBase64(string: string, options?: { urlSafe: boolean }): string; + +/** +Decode a Base64-encoded or [Base64URL](https://base64.guru/standards/base64url)-encoded string to a string. + +Replacement for `Buffer.from('SGVsbG8=', 'base64').toString()` and [`atob()`](https://developer.mozilla.org/en-US/docs/Web/API/atob). + +@example +``` +import {base64ToString} from 'uint8array-extras'; + +console.log(base64ToString('SGVsbG8=')); +//=> 'Hello' +``` +*/ +export function base64ToString(base64String: string): string; + +/** +Convert a `Uint8Array` to a Hex string. + +Replacement for [`Buffer#toString('hex')`](https://nodejs.org/api/buffer.html#buftostringencoding-start-end). + +@example +``` +import {uint8ArrayToHex} from 'uint8array-extras'; + +const byteArray = new Uint8Array([72, 101, 108, 108, 111]); + +console.log(uint8ArrayToHex(byteArray)); +//=> '48656c6c6f' +``` +*/ +export function uint8ArrayToHex(array: Uint8Array): string; + +/** +Convert a Hex string to a `Uint8Array`. + +Replacement for [`Buffer.from('48656c6c6f', 'hex')`](https://nodejs.org/api/buffer.html#static-method-bufferfromstring-encoding). + +@example +``` +import {hexToUint8Array} from 'uint8array-extras'; + +console.log(hexToUint8Array('48656c6c6f')); +//=> Uint8Array [72, 101, 108, 108, 111] +``` +*/ +export function hexToUint8Array(hexString: string): Uint8Array; + +/** +Read `DataView#byteLength` number of bytes from the given view, up to 48-bit. + +Replacement for [`Buffer#readUintBE`](https://nodejs.org/api/buffer.html#bufreadintbeoffset-bytelength) + +@example +``` +import {getUintBE} from 'uint8array-extras'; + +const byteArray = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x90, 0xab]); + +console.log(getUintBE(new DataView(byteArray.buffer))); +//=> 20015998341291 +``` +*/ +export function getUintBE(view: DataView): number; // eslint-disable-line @typescript-eslint/naming-convention + +/** +Find the index of the first occurrence of the given sequence of bytes (`value`) within the given `Uint8Array` (`array`). + +Replacement for [`Buffer#indexOf`](https://nodejs.org/api/buffer.html#bufindexofvalue-byteoffset-encoding). `Uint8Array#indexOf` only takes a number which is different from Buffer's `indexOf` implementation. + +@example +``` +import {indexOf} from 'uint8array-extras'; + +const byteArray = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]); + +console.log(indexOf(byteArray, new Uint8Array([0x78, 0x90]))); +//=> 3 +``` +*/ +export function indexOf(array: Uint8Array, value: Uint8Array): number; + +/** +Checks if the given sequence of bytes (`value`) is within the given `Uint8Array` (`array`). + +Returns true if the value is included, otherwise false. + +Replacement for [`Buffer#includes`](https://nodejs.org/api/buffer.html#bufincludesvalue-byteoffset-encoding). `Uint8Array#includes` only takes a number which is different from Buffer's `includes` implementation. + +``` +import {includes} from 'uint8array-extras'; + +const byteArray = new Uint8Array([0x12, 0x34, 0x56, 0x78, 0x90, 0xab, 0xcd, 0xef]); + +console.log(includes(byteArray, new Uint8Array([0x78, 0x90]))); +//=> true +``` +*/ +export function includes(array: Uint8Array, value: Uint8Array): boolean; + +/** + * Convert a Uint8Array (or ArrayBuffer) of UTF-8 bytes into a JS string. + * Only "utf8" is supported. For any other encoding you’ll need a polyfill. + * + * @param {Uint8Array|ArrayBuffer} input + * @param {string} [encoding="utf8"] + * @returns {string} + */ +export function uint8ArrayToString(array: Uint8Array, encoding?: string): string; \ No newline at end of file diff --git a/blue_modules/uint8array-extras/index.js b/blue_modules/uint8array-extras/index.js new file mode 100644 index 00000000000..84a03b774f0 --- /dev/null +++ b/blue_modules/uint8array-extras/index.js @@ -0,0 +1,410 @@ +/** + * author: Sindre Sorhus + * license: MIT + * source: https://github.com/sindresorhus/uint8array-extras + */ +const objectToString = Object.prototype.toString; +const uint8ArrayStringified = '[object Uint8Array]'; +const arrayBufferStringified = '[object ArrayBuffer]'; + +function isType(value, typeConstructor, typeStringified) { + if (!value) { + return false; + } + + if (value.constructor === typeConstructor) { + return true; + } + + return objectToString.call(value) === typeStringified; +} + +export function isUint8Array(value) { + return isType(value, Uint8Array, uint8ArrayStringified); +} + +function isArrayBuffer(value) { + return isType(value, ArrayBuffer, arrayBufferStringified); +} + +function isUint8ArrayOrArrayBuffer(value) { + return isUint8Array(value) || isArrayBuffer(value); +} + +export function assertUint8Array(value) { + if (!isUint8Array(value)) { + throw new TypeError(`Expected \`Uint8Array\`, got \`${typeof value}\``); + } +} + +export function assertUint8ArrayOrArrayBuffer(value) { + if (!isUint8ArrayOrArrayBuffer(value)) { + throw new TypeError(`Expected \`Uint8Array\` or \`ArrayBuffer\`, got \`${typeof value}\``); + } +} + +export function toUint8Array(value) { + if (value instanceof ArrayBuffer) { + return new Uint8Array(value); + } + + if (ArrayBuffer.isView(value)) { + return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); + } + + throw new TypeError(`Unsupported value, got \`${typeof value}\`.`); +} + +export function concatUint8Arrays(arrays, totalLength) { + if (arrays.length === 0) { + return new Uint8Array(0); + } + + totalLength ??= arrays.reduce((accumulator, currentValue) => accumulator + currentValue.length, 0); + + const returnValue = new Uint8Array(totalLength); + + let offset = 0; + for (const array of arrays) { + assertUint8Array(array); + returnValue.set(array, offset); + offset += array.length; + } + + return returnValue; +} + +export function areUint8ArraysEqual(a, b) { + assertUint8Array(a); + assertUint8Array(b); + + if (a === b) { + return true; + } + + if (a.length !== b.length) { + return false; + } + + // eslint-disable-next-line unicorn/no-for-loop + for (let index = 0; index < a.length; index++) { + if (a[index] !== b[index]) { + return false; + } + } + + return true; +} + +export function compareUint8Arrays(a, b) { + assertUint8Array(a); + assertUint8Array(b); + + const length = Math.min(a.length, b.length); + + for (let index = 0; index < length; index++) { + const diff = a[index] - b[index]; + if (diff !== 0) { + return Math.sign(diff); + } + } + + // At this point, all the compared elements are equal. + // The shorter array should come first if the arrays are of different lengths. + return Math.sign(a.length - b.length); +} + +// const cachedDecoders = { +// utf8: new globalThis.TextDecoder("utf8"), +// }; + +// +// !!!!!!! commented out because we dont have `TextDecoder` as dep anymore !!!!!!!! +// +// export function uint8ArrayToString(array, encoding = "utf8") { +// assertUint8ArrayOrArrayBuffer(array); +// cachedDecoders[encoding] ??= new globalThis.TextDecoder(encoding); +// return cachedDecoders[encoding].decode(array); +// } + +function assertString(value) { + if (typeof value !== 'string') { + throw new TypeError(`Expected \`string\`, got \`${typeof value}\``); + } +} + +const cachedEncoder = new globalThis.TextEncoder(); + +export function stringToUint8Array(string) { + assertString(string); + return cachedEncoder.encode(string); +} + +function base64ToBase64Url(base64) { + return base64.replaceAll('+', '-').replaceAll('/', '_').replace(/[=]+$/, ''); +} + +function base64UrlToBase64(base64url) { + return base64url.replaceAll('-', '+').replaceAll('_', '/'); +} + +// Reference: https://phuoc.ng/collection/this-vs-that/concat-vs-push/ +const MAX_BLOCK_SIZE = 65_535; + +export function uint8ArrayToBase64(array, { urlSafe = false } = {}) { + assertUint8Array(array); + + let base64; + + if (array.length < MAX_BLOCK_SIZE) { + // Required as `btoa` and `atob` don't properly support Unicode: https://developer.mozilla.org/en-US/docs/Glossary/Base64#the_unicode_problem + base64 = globalThis.btoa(String.fromCodePoint.apply(this, array)); + } else { + base64 = ''; + for (const value of array) { + base64 += String.fromCodePoint(value); + } + + base64 = globalThis.btoa(base64); + } + + return urlSafe ? base64ToBase64Url(base64) : base64; +} + +export function base64ToUint8Array(base64String) { + assertString(base64String); + return Uint8Array.from(globalThis.atob(base64UrlToBase64(base64String)), x => x.codePointAt(0)); +} + +export function stringToBase64(string, { urlSafe = false } = {}) { + assertString(string); + return uint8ArrayToBase64(stringToUint8Array(string), { urlSafe }); +} + +// export function base64ToString(base64String) { +// assertString(base64String); +// return uint8ArrayToString(base64ToUint8Array(base64String)); +// } + +const byteToHexLookupTable = Array.from({ length: 256 }, (_, index) => index.toString(16).padStart(2, '0')); + +export function uint8ArrayToHex(array) { + assertUint8Array(array); + + // Concatenating a string is faster than using an array. + let hexString = ''; + + // eslint-disable-next-line unicorn/no-for-loop -- Max performance is critical. + for (let index = 0; index < array.length; index++) { + hexString += byteToHexLookupTable[array[index]]; + } + + return hexString; +} + +const hexToDecimalLookupTable = { + 0: 0, + 1: 1, + 2: 2, + 3: 3, + 4: 4, + 5: 5, + 6: 6, + 7: 7, + 8: 8, + 9: 9, + a: 10, + b: 11, + c: 12, + d: 13, + e: 14, + f: 15, + A: 10, + B: 11, + C: 12, + D: 13, + E: 14, + F: 15, +}; + +export function hexToUint8Array(hexString) { + assertString(hexString); + + if (hexString.length % 2 !== 0) { + throw new Error('Invalid Hex string length.'); + } + + const resultLength = hexString.length / 2; + const bytes = new Uint8Array(resultLength); + + for (let index = 0; index < resultLength; index++) { + const highNibble = hexToDecimalLookupTable[hexString[index * 2]]; + const lowNibble = hexToDecimalLookupTable[hexString[index * 2 + 1]]; + + if (highNibble === undefined || lowNibble === undefined) { + throw new Error(`Invalid Hex character encountered at position ${index * 2}`); + } + + bytes[index] = (highNibble << 4) | lowNibble; // eslint-disable-line no-bitwise + } + + return bytes; +} + +/** +@param {DataView} view +@returns {number} +*/ +export function getUintBE(view) { + const { byteLength } = view; + + if (byteLength === 6) { + return view.getUint16(0) * 2 ** 32 + view.getUint32(2); + } + + if (byteLength === 5) { + return view.getUint8(0) * 2 ** 32 + view.getUint32(1); + } + + if (byteLength === 4) { + return view.getUint32(0); + } + + if (byteLength === 3) { + return view.getUint8(0) * 2 ** 16 + view.getUint16(1); + } + + if (byteLength === 2) { + return view.getUint16(0); + } + + if (byteLength === 1) { + return view.getUint8(0); + } +} + +/** +@param {Uint8Array} array +@param {Uint8Array} value +@returns {number} +*/ +export function indexOf(array, value) { + const arrayLength = array.length; + const valueLength = value.length; + + if (valueLength === 0) { + return -1; + } + + if (valueLength > arrayLength) { + return -1; + } + + const validOffsetLength = arrayLength - valueLength; + + for (let index = 0; index <= validOffsetLength; index++) { + let isMatch = true; + for (let index2 = 0; index2 < valueLength; index2++) { + if (array[index + index2] !== value[index2]) { + isMatch = false; + break; + } + } + + if (isMatch) { + return index; + } + } + + return -1; +} + +/** +@param {Uint8Array} array +@param {Uint8Array} value +@returns {boolean} +*/ +export function includes(array, value) { + return indexOf(array, value) !== -1; +} + +// we can use this implementation when we will have TextDecoder in RN +// const cachedDecoders = { +// utf8: new globalThis.TextDecoder("utf8"), +// }; +// export function uint8ArrayToString(array, encoding = "utf8") { +// assertUint8ArrayOrArrayBuffer(array); +// cachedDecoders[encoding] ??= new globalThis.TextDecoder(encoding); +// return cachedDecoders[encoding].decode(array); +// } +// meanwhile: + +/** + * Convert a Uint8Array (or ArrayBuffer) of UTF-8 bytes into a JS string. + * Only "utf8" is supported. For any other encoding you’ll need a polyfill. + * + * @param {Uint8Array|ArrayBuffer} input + * @param {string} [encoding="utf8"] + * @returns {string} + */ +export function uint8ArrayToString(input, encoding = 'utf8') { + assertUint8ArrayOrArrayBuffer(input); + + // Reject anything other than UTF-8 + if (!/utf-?8/i.test(encoding)) { + throw new Error('Encoding "' + encoding + '" isn’t supported without a TextDecoder polyfill'); + } + + // Normalise to Uint8Array + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + return decodeUtf8(bytes); +} + +/** + * Minimal UTF-8 decoder + * @param {Uint8Array} bytes + * @returns {string} + */ +function decodeUtf8(bytes) { + let i = 0; + const l = bytes.length; + const codeUnits = []; + let result = ''; + + while (i < l) { + const byte1 = bytes[i++]; + + // 1-byte (ASCII) + if (byte1 < 0x80) { + codeUnits.push(byte1); + } + // 2-byte + else if (byte1 < 0xe0) { + const byte2 = bytes[i++] & 0x3f; + codeUnits.push(((byte1 & 0x1f) << 6) | byte2); + } + // 3-byte + else if (byte1 < 0xf0) { + const byte2 = bytes[i++] & 0x3f; + const byte3 = bytes[i++] & 0x3f; + codeUnits.push(((byte1 & 0x0f) << 12) | (byte2 << 6) | byte3); + } + // 4-byte (→ surrogate pair) + else { + const byte2 = bytes[i++] & 0x3f; + const byte3 = bytes[i++] & 0x3f; + const byte4 = bytes[i++] & 0x3f; + let cp = ((byte1 & 0x07) << 18) | (byte2 << 12) | (byte3 << 6) | byte4; + cp -= 0x10000; + codeUnits.push(0xd800 + (cp >> 10), 0xdc00 + (cp & 0x3ff)); + } + + // Flush periodically to avoid huge apply() calls + if (codeUnits.length > 0x8000) { + result += String.fromCharCode.apply(null, codeUnits); + codeUnits.length = 0; + } + } + + return result + String.fromCharCode.apply(null, codeUnits); +} diff --git a/blue_modules/ur/index.js b/blue_modules/ur/index.js index 6ae16df28df..1b7bcf056bc 100644 --- a/blue_modules/ur/index.js +++ b/blue_modules/ur/index.js @@ -1,23 +1,42 @@ -import { URDecoder } from '@ngraveio/bc-ur'; -import b58 from 'bs58check'; +// Must be imported first to register CBOR semantic decoders (tag 303/304/etc.) +// Without this, nested tagged items inside crypto-multi-accounts/crypto-hdkey +// are decoded as plain Objects instead of DataItem instances, causing getData() errors. +import '@keystonehq/bc-ur-registry/dist/patchCBOR'; import { + Bytes, + CryptoAccount, CryptoHDKey, CryptoKeypath, CryptoOutput, + CryptoPSBT, PathComponent, ScriptExpressions, - CryptoPSBT, - CryptoAccount, - Bytes, + CryptoMultiAccounts, } from '@keystonehq/bc-ur-registry/dist'; -import { decodeUR as origDecodeUr, encodeUR as origEncodeUR, extractSingleWorkload as origExtractSingleWorkload } from '../bc-ur/dist'; -import { MultisigCosigner, MultisigHDWallet } from '../../class'; -import { Psbt } from 'bitcoinjs-lib'; +import { URDecoder } from '@ngraveio/bc-ur'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Psbt } from 'bitcoinjs-lib'; +import b58 from 'bs58check'; + +import { MultisigCosigner } from '../../class/multisig-cosigner'; +import { MultisigHDWallet } from '../../class/wallets/multisig-hd-wallet'; +import { joinQRs } from '../bbqr/join'; +import { + concatUint8Arrays, + hexToUint8Array, + stringToUint8Array, + uint8ArrayToHex, + uint8ArrayToBase64, + uint8ArrayToString, +} from '../uint8array-extras'; +import { splitQRs } from '../bbqr/split'; +import { decodeUR as origDecodeUr, encodeUR as origEncodeUR, extractSingleWorkload as origExtractSingleWorkload } from '../bc-ur/dist'; const USE_UR_V1 = 'USE_UR_V1'; +const USE_BBQR_WALLET_IDS = 'USE_BBQR_WALLET_IDS'; let useURv1 = false; +let useBBQRWalletIDs = []; (async () => { try { @@ -25,6 +44,17 @@ let useURv1 = false; } catch (_) {} })(); +(async () => { + try { + // initial load of wallets that must use BBQR for animated QR codes + const json = await AsyncStorage.getItem(USE_BBQR_WALLET_IDS); + const parsed = JSON.parse(json); + if (Array.isArray(parsed)) { + useBBQRWalletIDs = parsed; + } + } catch (_) {} +})(); + async function isURv1Enabled() { try { return !!(await AsyncStorage.getItem(USE_UR_V1)); @@ -38,13 +68,51 @@ async function setUseURv1() { return AsyncStorage.setItem(USE_UR_V1, '1'); } +async function setWalletIdMustUseBBQR(walletID) { + console.log('setting walletID to useBBQR:', walletID); + useBBQRWalletIDs.push(walletID); + await AsyncStorage.setItem(USE_BBQR_WALLET_IDS, JSON.stringify(useBBQRWalletIDs)); +} + async function clearUseURv1() { useURv1 = false; return AsyncStorage.removeItem(USE_UR_V1); } -function encodeUR(arg1, arg2) { - return useURv1 ? encodeURv1(arg1, arg2) : encodeURv2(arg1, arg2); +/** + * + * @param value {string} payload to render in QR + * @param capacity {number?} Bytes per QR fragment + * @param walletID {string?} Optional, if we previously saved preferences for that wallet (which protocol to use) + * @param forceProtocol {'auto' | 'BBQR' | 'URv2' = 'auto'} + * @returns {string[]} + */ +function encodeUR(value, capacity = 175, walletID, forceProtocol = 'auto') { + if (forceProtocol === 'URv2') { + return useURv1 ? encodeURv1(value, capacity) : encodeURv2(value, capacity); + } + + if (forceProtocol === 'BBQR' || (walletID && useBBQRWalletIDs.includes(walletID))) { + // payload should be hex + if (!isHexString(value)) { + value = uint8ArrayToHex(stringToUint8Array(value)); + } + + const minSplit = Math.max(1, Math.ceil(value.length / 2 / capacity)); + + if (uint8ArrayToString(hexToUint8Array(value)).startsWith('psbt')) { + // its a PSBT! + const ret = splitQRs(hexToUint8Array(value), 'P', { minSplit }); + return ret.parts; + } + + // its a random utf8 text! + const ret = splitQRs(hexToUint8Array(value), 'U', { minSplit }); + return ret.parts; + } // end BBQR + + // auto (aka default): + return useURv1 ? encodeURv1(value, capacity) : encodeURv2(value, capacity); } function encodeURv1(arg1, arg2) { @@ -57,10 +125,14 @@ function encodeURv1(arg1, arg2) { return origEncodeUR(arg1, arg2); } +function isHexString(s) { + return /^[0-9a-fA-F]*$/.test(s) && s.length % 2 === 0; +} + /** * * @param str {string} For PSBT, or coordination setup (translates to `bytes`) it expects hex string. For ms cosigner it expects plain json string - * @param len {number} lenght of each fragment + * @param len {number} length of each fragment * @return {string[]} txt fragments ready to be displayed in dynamic QR */ function encodeURv2(str, len) { @@ -125,7 +197,6 @@ function encodeURv2(str, len) { } catch (_) {} // fail. fallback to bytes - const bytes = new Bytes(Buffer.from(str, 'hex')); const encoder = bytes.toUREncoder(len); @@ -186,33 +257,135 @@ function decodeUR(arg) { derivationPath === MultisigHDWallet.PATH_LEGACY || derivationPath === MultisigHDWallet.PATH_WRAPPED_SEGWIT || derivationPath === MultisigHDWallet.PATH_NATIVE_SEGWIT; - const version = Buffer.from(isMultisig ? '02aa7ed3' : '04b24746', 'hex'); + const version = hexToUint8Array(isMultisig ? '02aa7ed3' : '04b24746'); const parentFingerprint = hdKey.getParentFingerprint(); const depth = hdKey.getOrigin().getDepth(); - const depthBuf = Buffer.alloc(1); - depthBuf.writeUInt8(depth); + const depthBuf = new Uint8Array(1); + depthBuf[0] = depth; const components = hdKey.getOrigin().getComponents(); const lastComponents = components[components.length - 1]; const index = lastComponents.isHardened() ? lastComponents.getIndex() + 0x80000000 : lastComponents.getIndex(); - const indexBuf = Buffer.alloc(4); - indexBuf.writeUInt32BE(index); + const indexBuf = new Uint8Array(4); + new DataView(indexBuf.buffer).setUint32(0, index, false); // big-endian const chainCode = hdKey.getChainCode(); const key = hdKey.getKey(); - const data = Buffer.concat([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); + const data = concatUint8Arrays([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); const zpub = b58.encode(data); const result = {}; result.ExtPubKey = zpub; - result.MasterFingerprint = cryptoAccount.getMasterFingerprint().toString('hex').toUpperCase(); + result.MasterFingerprint = uint8ArrayToHex(cryptoAccount.getMasterFingerprint()).toUpperCase(); result.AccountKeyPath = derivationPath; const str = JSON.stringify(result); - return Buffer.from(str, 'ascii').toString('hex'); // we are expected to return hex-encoded string + return uint8ArrayToHex(stringToUint8Array(str)); // we are expected to return hex-encoded string +} + +/** + * Convert a CryptoHDKey to the {ExtPubKey, MasterFingerprint, AccountKeyPath} result + * format that BlueWallet uses for watch-only / hardware wallet import. + * + * Returns null (key is skipped) when: + * - the key has no origin (can't determine derivation path) + * - the key has no path components (e.g. a bare master key) + * - the key is missing chainCode or public key bytes + * - the coin type is not Bitcoin (0) – hardware wallets like OneKey send keys for + * multiple chains (ETH coin=60, SOL coin=501 …) in a single crypto-multi-accounts + * payload; BlueWallet is Bitcoin-only so non-Bitcoin keys must be filtered out. + * + * @param {CryptoHDKey} hdKey + * @param {string|null} masterFingerprintOverride + * Pass the master fingerprint from the outer CryptoMultiAccounts object when + * processing a multi-accounts payload, because individual HDKey entries may not + * carry the master fingerprint themselves. + */ +function _hdKeyToResult(hdKey, masterFingerprintOverride) { + const origin = hdKey.getOrigin(); + if (!origin) return null; + + const components = origin.getComponents(); + if (!components || components.length === 0) return null; + + // Coin type is the second path component (index 1): m / purpose' / coin_type' / account' + // BIP-44 standard: coin type 0 = Bitcoin mainnet. + // Skip non-Bitcoin keys (ETH=60, TRX=195, SOL=501, etc.) so they don't appear as + // blank wallets in the BlueWallet import UI. + if (components.length >= 2 && components[1].getIndex() !== 0) return null; + + const chainCode = hdKey.getChainCode(); + const key = hdKey.getKey(); + if (!chainCode || !key) return null; + + const derivationPath = 'm/' + origin.getPath(); + + // Multisig wallets use a different xpub version prefix than single-sig. + const isMultisig = + derivationPath === MultisigHDWallet.PATH_LEGACY || + derivationPath === MultisigHDWallet.PATH_WRAPPED_SEGWIT || + derivationPath === MultisigHDWallet.PATH_NATIVE_SEGWIT; + + // Default version bytes produce a zpub (native segwit / BIP-84). + // For multisig we use Zprv/Zpub version bytes instead. + const version = hexToUint8Array(isMultisig ? '02aa7ed3' : '04b24746'); + + const parentFingerprint = hdKey.getParentFingerprint() || new Uint8Array(4); + // depth may be encoded in the origin or inferred from the number of path components + const depth = origin.getDepth() || components.length; + const depthBuf = new Uint8Array(1); + depthBuf[0] = depth; + + const lastComponent = components[components.length - 1]; + const index = lastComponent.isHardened() ? lastComponent.getIndex() + 0x80000000 : lastComponent.getIndex(); + const indexBuf = new Uint8Array(4); + new DataView(indexBuf.buffer).setUint32(0, index, false); + + const keyData = concatUint8Arrays([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); + + const result = {}; + result.ExtPubKey = b58.encode(keyData); + result.MasterFingerprint = + masterFingerprintOverride || + (origin.getSourceFingerprint() ? uint8ArrayToHex(origin.getSourceFingerprint()).toUpperCase() : ''); + result.AccountKeyPath = derivationPath; + + // Re-encode with the correct version bytes for the specific script type so that + // BlueWallet can recognise the wallet type from the prefix alone (xpub/ypub/zpub). + if (derivationPath.startsWith("m/49'/0'/")) { + // BIP-49 P2SH-P2WPKH → ypub (version 0x049d7cb2) + let d = b58.decode(result.ExtPubKey); + d = d.slice(4); + result.ExtPubKey = b58.encode(concatUint8Arrays([hexToUint8Array('049d7cb2'), d])); + } + if (derivationPath.startsWith("m/44'/0'/")) { + // BIP-44 P2PKH → xpub (version 0x0488b21e) + let d = b58.decode(result.ExtPubKey); + d = d.slice(4); + result.ExtPubKey = b58.encode(concatUint8Arrays([hexToUint8Array('0488b21e'), d])); + } + // BIP-84 m/84'/0'/ keeps the default zpub version (0x04b24746) – no re-encoding needed. + // BIP-86 m/86'/0'/ (Taproot) also falls through with zpub bytes; BlueWallet detects + // Taproot via the derivation path rather than the version prefix. + + return result; } class BlueURDecoder extends URDecoder { + bbqrParts = {}; // key-value, payload->1 + toString() { + if (Object.keys(this.bbqrParts).length > 0) { + // its BBQR, handle differently + const decodedBbqr = joinQRs(Object.keys(this.bbqrParts)); + if (decodedBbqr.fileType === 'P') { + // if its psbt we return base64: + return uint8ArrayToBase64(decodedBbqr.raw); + } + + // for everything else we covnert bytes to string directly + return uint8ArrayToString(decodedBbqr.raw); + } + const decoded = this.resultUR(); if (decoded.type === 'crypto-psbt') { @@ -222,7 +395,8 @@ class BlueURDecoder extends URDecoder { if (decoded.type === 'bytes') { const bytes = Bytes.fromCBOR(decoded.cbor); - return Buffer.from(bytes.getData(), 'hex').toString('ascii'); + const data = bytes.getData(); + return uint8ArrayToString(data); } if (decoded.type === 'crypto-account') { @@ -241,39 +415,39 @@ class BlueURDecoder extends URDecoder { derivationPath === MultisigHDWallet.PATH_LEGACY || derivationPath === MultisigHDWallet.PATH_WRAPPED_SEGWIT || derivationPath === MultisigHDWallet.PATH_NATIVE_SEGWIT; - const version = Buffer.from(isMultisig ? '02aa7ed3' : '04b24746', 'hex'); + const version = hexToUint8Array(isMultisig ? '02aa7ed3' : '04b24746'); const parentFingerprint = hdKey.getParentFingerprint(); const depth = hdKey.getOrigin().getDepth(); - const depthBuf = Buffer.alloc(1); - depthBuf.writeUInt8(depth); + const depthBuf = new Uint8Array(1); + depthBuf[0] = depth; const components = hdKey.getOrigin().getComponents(); const lastComponents = components[components.length - 1]; const index = lastComponents.isHardened() ? lastComponents.getIndex() + 0x80000000 : lastComponents.getIndex(); - const indexBuf = Buffer.alloc(4); - indexBuf.writeUInt32BE(index); + const indexBuf = new Uint8Array(4); + new DataView(indexBuf.buffer).setUint32(0, index, false); // big-endian const chainCode = hdKey.getChainCode(); const key = hdKey.getKey(); - const data = Buffer.concat([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); + const data = concatUint8Arrays([version, depthBuf, parentFingerprint, indexBuf, chainCode, key]); const zpub = b58.encode(data); const result = {}; result.ExtPubKey = zpub; - result.MasterFingerprint = cryptoAccount.getMasterFingerprint().toString('hex').toUpperCase(); + result.MasterFingerprint = uint8ArrayToHex(cryptoAccount.getMasterFingerprint()).toUpperCase(); result.AccountKeyPath = derivationPath; if (derivationPath.startsWith("m/49'/0'/")) { // converting to ypub let data = b58.decode(result.ExtPubKey); data = data.slice(4); - result.ExtPubKey = b58.encode(Buffer.concat([Buffer.from('049d7cb2', 'hex'), data])); + result.ExtPubKey = b58.encode(concatUint8Arrays([hexToUint8Array('049d7cb2'), data])); } if (derivationPath.startsWith("m/44'/0'/")) { // converting to xpub let data = b58.decode(result.ExtPubKey); data = data.slice(4); - result.ExtPubKey = b58.encode(Buffer.concat([Buffer.from('0488b21e', 'hex'), data])); + result.ExtPubKey = b58.encode(concatUint8Arrays([hexToUint8Array('0488b21e'), data])); } results.push(result); @@ -287,8 +461,85 @@ class BlueURDecoder extends URDecoder { return output.toString(); } - throw new Error('unsupported data format'); + if (decoded.type === 'crypto-hdkey') { + const hdKey = CryptoHDKey.fromCBOR(decoded.cbor); + const result = _hdKeyToResult(hdKey, null); + if (!result) throw new Error('crypto-hdkey: missing origin or components'); + return JSON.stringify([result]); + } + + if (decoded.type === 'crypto-multi-accounts') { + const multiAccounts = CryptoMultiAccounts.fromCBOR(decoded.cbor); + const masterFingerprint = uint8ArrayToHex(multiAccounts.getMasterFingerprint()).toUpperCase(); + + const results = []; + for (const hdKey of multiAccounts.getKeys()) { + // skip keys without a valid Bitcoin derivation path (e.g. ETH/SOL keys) + const result = _hdKeyToResult(hdKey, masterFingerprint); + if (result) results.push(result); + } + + if (results.length === 0) throw new Error('crypto-multi-accounts: no valid Bitcoin keys found'); + return JSON.stringify(results); + } + + // For all other UR types (e.g. btc-signature, eth-signature, sol-signature), + // return the raw CBOR hex so callers can handle it if needed. + return decoded.cbor.toString('hex'); + } + + isComplete() { + if (Object.keys(this.bbqrParts).length > 0) { + // its BBQR, handle differently + const bbqrPayload = Object.keys(this.bbqrParts)[0]; + if (bbqrPayload.slice(0, 2) !== 'B$') { + throw new Error('fixed header not found, expected B$'); + } + + const numParts = parseInt(bbqrPayload.slice(4, 6), 36); + return Object.keys(this.bbqrParts).length >= numParts; + } + + // fallback to old BC-UR mechanism + return super.isComplete(); + } + + estimatedPercentComplete() { + if (Object.keys(this.bbqrParts).length > 0) { + // its BBQR, handle differently + const bbqrPayload = Object.keys(this.bbqrParts)[0]; + if (bbqrPayload.slice(0, 2) !== 'B$') { + throw new Error('fixed header not found, expected B$'); + } + + const numParts = parseInt(bbqrPayload.slice(4, 6), 36); + return Object.keys(this.bbqrParts).length / numParts; + } + + // fallback to old BC-UR mechanism + return super.estimatedPercentComplete(); + } + + receivePart(s) { + if (s.startsWith('B$')) { + // its BBQR, handle differently + this.bbqrParts[s] = true; + return true; + } + + // fallback to old BC-UR mechanism + return super.receivePart(s); } } -export { decodeUR, encodeUR, extractSingleWorkload, BlueURDecoder, isURv1Enabled, setUseURv1, clearUseURv1 }; +export { + decodeUR, + encodeUR, + extractSingleWorkload, + BlueURDecoder, + isURv1Enabled, + setUseURv1, + clearUseURv1, + setWalletIdMustUseBBQR, + isHexString, +}; diff --git a/bugsnag.js b/bugsnag.js new file mode 100644 index 00000000000..6b4de6bf078 --- /dev/null +++ b/bugsnag.js @@ -0,0 +1,18 @@ +import Bugsnag from '@bugsnag/react-native'; +import DefaultPreference from 'react-native-default-preference'; + +const GROUP_IO_BLUEWALLET = 'group.io.bluewallet.bluewallet'; +const DO_NOT_TRACK_KEY = 'donottrack'; + +(async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const doNotTrack = await DefaultPreference.get(DO_NOT_TRACK_KEY); + if (doNotTrack === '1') return; + + Bugsnag.start(); + } catch (error) { + // Never let analytics setup crash the app. + console.error('Failed to initialize Bugsnag:', error); + } +})(); diff --git a/class/azteco.js b/class/azteco.js deleted file mode 100644 index 78cc4d9e0b6..00000000000 --- a/class/azteco.js +++ /dev/null @@ -1,41 +0,0 @@ -import Frisbee from 'frisbee'; -import URL from 'url'; - -export default class Azteco { - /** - * Redeems an Azteco bitcoin voucher. - * - * @param {string[]} voucher - 16-digit voucher code in groups of 4. - * @param {string} address - Bitcoin address to send the redeemed bitcoin to. - * - * @returns {Promise} Successfully redeemed or not. This method does not throw exceptions - */ - static async redeem(voucher, address) { - const api = new Frisbee({ - baseURI: 'https://azte.co/', - }); - const url = `/blue_despatch.php?CODE_1=${voucher[0]}&CODE_2=${voucher[1]}&CODE_3=${voucher[2]}&CODE_4=${voucher[3]}&ADDRESS=${address}`; - - try { - const response = await api.get(url); - return response && response.originalResponse && +response.originalResponse.status === 200; - } catch (_) { - return false; - } - } - - static isRedeemUrl(u) { - return u.startsWith('https://azte.co'); - } - - static getParamsFromUrl(u) { - const urlObject = URL.parse(u, true); // eslint-disable-line n/no-deprecated-api - return { - uri: u, - c1: urlObject.query.c1, - c2: urlObject.query.c2, - c3: urlObject.query.c3, - c4: urlObject.query.c4, - }; - } -} diff --git a/class/azteco.ts b/class/azteco.ts new file mode 100644 index 00000000000..6aad7d27edd --- /dev/null +++ b/class/azteco.ts @@ -0,0 +1,78 @@ +import URL from 'url'; +import { fetch } from '../util/fetch'; + +export type AztecoVoucher = { + c1: string; + c2: string; + c3: string; + c4: string; +}; + +export default class Azteco { + /** + * Redeems an Azteco bitcoin voucher. + * + * @param {AztecoVoucher} voucher - 16-digit voucher code in groups of 4. + * @param {string} address - Bitcoin address to send the redeemed bitcoin to. + * + * @returns {Promise} Successfully redeemed or not. This method does not throw exceptions + */ + static async redeem(voucher: AztecoVoucher, address: string): Promise { + const baseURI = 'https://azte.co/'; + const url = `${baseURI}blue_despatch.php?CODE_1=${voucher.c1}&CODE_2=${voucher.c2}&CODE_3=${voucher.c3}&CODE_4=${voucher.c4}&ADDRESS=${address}`; + + try { + const response = await fetch(url, { + method: 'GET', + }); + return response && response.status === 200; + } catch (_) { + return false; + } + } + + static isRedeemUrl(u: string): boolean { + return u.startsWith('https://azte.co'); + } + + static getParamsFromUrl(u: string): { aztecoVoucher: AztecoVoucher } { + const urlObject = URL.parse(u, true); // eslint-disable-line n/no-deprecated-api + const q = urlObject.query; + + // new format https://azte.co/redeem?code=1111222233334444 + if (typeof q.code === 'string' && q.code.length === 16) { + return { + aztecoVoucher: { + c1: q.code.substring(0, 4), + c2: q.code.substring(4, 8), + c3: q.code.substring(8, 12), + c4: q.code.substring(12, 16), + }, + }; + } + + // old format https://azte.co?c1=1111&c2=2222&c3=3333&c4=4444 + if ( + typeof q.c1 === 'string' && + typeof q.c2 === 'string' && + typeof q.c3 === 'string' && + typeof q.c4 === 'string' && + q.c1.length === 4 && + q.c2.length === 4 && + q.c3.length === 4 && + q.c4.length === 4 + ) { + return { + aztecoVoucher: { + c1: q.c1, + c2: q.c2, + c3: q.c3, + c4: q.c4, + }, + }; + } + + // if the url does not match any of the formats, throw an error + throw new Error('Invalid Azteco URL'); + } +} diff --git a/class/biometrics.js b/class/biometrics.js deleted file mode 100644 index 14d2b362677..00000000000 --- a/class/biometrics.js +++ /dev/null @@ -1,147 +0,0 @@ -import FingerprintScanner from 'react-native-fingerprint-scanner'; -import { Platform, Alert } from 'react-native'; -import PasscodeAuth from 'react-native-passcode-auth'; -import * as NavigationService from '../NavigationService'; -import { StackActions, CommonActions } from '@react-navigation/native'; -import RNSecureKeyStore from 'react-native-secure-key-store'; -import loc from '../loc'; -import { useContext } from 'react'; -import { BlueStorageContext } from '../blue_modules/storage-context'; -import alert from '../components/Alert'; - -function Biometric() { - const { getItem, setItem } = useContext(BlueStorageContext); - Biometric.STORAGEKEY = 'Biometrics'; - Biometric.FaceID = 'Face ID'; - Biometric.TouchID = 'Touch ID'; - Biometric.Biometrics = 'Biometrics'; - - Biometric.isDeviceBiometricCapable = async () => { - try { - const isDeviceBiometricCapable = await FingerprintScanner.isSensorAvailable(); - if (isDeviceBiometricCapable) { - return true; - } - } catch (e) { - console.log('Biometrics isDeviceBiometricCapable failed'); - console.log(e); - Biometric.setBiometricUseEnabled(false); - return false; - } - }; - - Biometric.biometricType = async () => { - try { - const isSensorAvailable = await FingerprintScanner.isSensorAvailable(); - return isSensorAvailable; - } catch (e) { - console.log('Biometrics biometricType failed'); - console.log(e); - } - return false; - }; - - Biometric.isBiometricUseEnabled = async () => { - try { - const enabledBiometrics = await getItem(Biometric.STORAGEKEY); - return !!enabledBiometrics; - } catch (_) {} - - return false; - }; - - Biometric.isBiometricUseCapableAndEnabled = async () => { - const isBiometricUseEnabled = await Biometric.isBiometricUseEnabled(); - const isDeviceBiometricCapable = await Biometric.isDeviceBiometricCapable(); - return isBiometricUseEnabled && isDeviceBiometricCapable; - }; - - Biometric.setBiometricUseEnabled = async value => { - await setItem(Biometric.STORAGEKEY, value === true ? '1' : ''); - }; - - Biometric.unlockWithBiometrics = async () => { - const isDeviceBiometricCapable = await Biometric.isDeviceBiometricCapable(); - if (isDeviceBiometricCapable) { - return new Promise(resolve => { - FingerprintScanner.authenticate({ description: loc.settings.biom_conf_identity, fallbackEnabled: true }) - .then(() => resolve(true)) - .catch(error => { - console.log('Biometrics authentication failed'); - console.log(error); - resolve(false); - }) - .finally(() => FingerprintScanner.release()); - }); - } - return false; - }; - - Biometric.clearKeychain = async () => { - await RNSecureKeyStore.remove('data'); - await RNSecureKeyStore.remove('data_encrypted'); - await RNSecureKeyStore.remove(Biometric.STORAGEKEY); - NavigationService.dispatch(StackActions.replace('WalletsRoot')); - }; - - Biometric.requestDevicePasscode = async () => { - let isDevicePasscodeSupported = false; - try { - isDevicePasscodeSupported = await PasscodeAuth.isSupported(); - if (isDevicePasscodeSupported) { - const isAuthenticated = await PasscodeAuth.authenticate(); - if (isAuthenticated) { - Alert.alert( - loc.settings.encrypt_tstorage, - loc.settings.biom_remove_decrypt, - [ - { text: loc._.cancel, style: 'cancel' }, - { - text: loc._.ok, - onPress: () => Biometric.clearKeychain(), - }, - ], - { cancelable: false }, - ); - } - } - } catch { - isDevicePasscodeSupported = undefined; - } - if (isDevicePasscodeSupported === false) { - alert(loc.settings.biom_no_passcode); - } - }; - - Biometric.showKeychainWipeAlert = () => { - if (Platform.OS === 'ios') { - Alert.alert( - loc.settings.encrypt_tstorage, - loc.settings.biom_10times, - [ - { - text: loc._.cancel, - onPress: () => { - NavigationService.dispatch( - CommonActions.setParams({ - index: 0, - routes: [{ name: 'UnlockWithScreenRoot' }, { params: { unlockOnComponentMount: false } }], - }), - ); - }, - style: 'cancel', - }, - { - text: loc._.ok, - onPress: () => Biometric.requestDevicePasscode(), - style: 'default', - }, - ], - { cancelable: false }, - ); - } - }; - return null; -} - -export default Biometric; diff --git a/class/bip39_wallet_formats.json b/class/bip39_wallet_formats.json index 8fb92c17cfb..dc0d77af2da 100644 --- a/class/bip39_wallet_formats.json +++ b/class/bip39_wallet_formats.json @@ -35,6 +35,30 @@ "script_type": "p2wpkh", "iterate_accounts": true }, + { + "description": "Non-standard legacy on BIP84 path", + "derivation_path": "m/84'/0'/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Non-standard compatibility segwit on BIP84 path", + "derivation_path": "m/84'/0'/0'", + "script_type": "p2wpkh-p2sh", + "iterate_accounts": true + }, + { + "description": "Non-standard legacy on BIP49 path", + "derivation_path": "m/49'/0'/0'", + "script_type": "p2pkh", + "iterate_accounts": true + }, + { + "description": "Non-standard native segwit on BIP49 path", + "derivation_path": "m/49'/0'/0'", + "script_type": "p2wpkh", + "iterate_accounts": true + }, { "description": "Copay native segwit", "derivation_path": "m/44'/0'/0'", diff --git a/class/bip39_wallet_formats_bluewallet.json b/class/bip39_wallet_formats_bluewallet.json index b7394b2ea61..4f95e10aa49 100644 --- a/class/bip39_wallet_formats_bluewallet.json +++ b/class/bip39_wallet_formats_bluewallet.json @@ -16,5 +16,11 @@ "derivation_path": "m/84'/0'/0'", "script_type": "p2wpkh", "iterate_accounts": false + }, + { + "description": "Standard BIP86 native taproot", + "derivation_path": "m/86'/0'/0'", + "script_type": "p2tr", + "iterate_accounts": false } ] diff --git a/class/blue-app.ts b/class/blue-app.ts new file mode 100644 index 00000000000..5f9bbf658c3 --- /dev/null +++ b/class/blue-app.ts @@ -0,0 +1,953 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { sha256 } from '@noble/hashes/sha256'; +import DefaultPreference from 'react-native-default-preference'; +import RNFS from 'react-native-fs'; +import Keychain from 'react-native-keychain'; +import RNSecureKeyStore, { ACCESSIBLE } from 'react-native-secure-key-store'; +import Realm from 'realm'; + +import * as encryption from '../blue_modules/encryption'; +import presentAlert from '../components/Alert'; +import { randomBytes } from './rng'; +import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './wallets/legacy-wallet'; +import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets'; +import { ExtendedTransaction, Transaction, TWallet } from './wallets/types'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; +import { getLNDHub } from '../helpers/lndHub'; +import { LightningArkWallet } from './wallets/lightning-ark-wallet.ts'; +import { hexToUint8Array, uint8ArrayToHex } from '../blue_modules/uint8array-extras'; +import { HDTaprootWallet } from './wallets/hd-taproot-wallet'; + +let usedBucketNum: boolean | number = false; +let savingInProgress = 0; // its both a flag and a counter of attempts to write to disk + +export type TTXMetadata = { + [txid: string]: { + memo?: string; + }; +}; + +export type TCounterpartyMetadata = { + /** + * our contact identifier, such as bip47 payment code + */ + [counterparty: string]: { + /** + * custom human-readable name we assign ourselves + */ + label: string; + /** + * some counterparties cannot be deleted because they sent a notif tx onchain, so we just mark them as hidden when user deletes + */ + hidden?: boolean; + }; +}; + +type TRealmTransaction = { + internal: boolean; + index: number; + tx: string; +}; + +type TBucketStorage = { + wallets: string[]; // array of serialized wallets, not actual wallet objects + tx_metadata: TTXMetadata; + counterparty_metadata: TCounterpartyMetadata; +}; + +const isReactNative = typeof navigator !== 'undefined' && navigator?.product === 'ReactNative'; + +export class BlueApp { + static FLAG_ENCRYPTED = 'data_encrypted'; + static LNDHUB = 'lndhub'; + static DO_NOT_TRACK = 'donottrack'; + static HANDOFF_STORAGE_KEY = 'HandOff'; + + private static _instance: BlueApp | null = null; + + static keys2migrate = [BlueApp.HANDOFF_STORAGE_KEY, BlueApp.DO_NOT_TRACK]; + + public cachedPassword?: false | string; + public tx_metadata: TTXMetadata; + public counterparty_metadata: TCounterpartyMetadata; + public wallets: TWallet[]; + + constructor() { + this.wallets = []; + this.tx_metadata = {}; + this.counterparty_metadata = {}; + this.cachedPassword = false; + } + + static getInstance(): BlueApp { + if (!BlueApp._instance) { + BlueApp._instance = new BlueApp(); + } + + return BlueApp._instance; + } + + async migrateKeys() { + // do not migrate keys if we are not in RN env + if (!isReactNative) { + return; + } + + for (const key of BlueApp.keys2migrate) { + try { + const value = await RNSecureKeyStore.get(key); + if (value) { + await AsyncStorage.setItem(key, value); + await RNSecureKeyStore.remove(key); + } + } catch (_) {} + } + } + + /** + * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is + * used for cli/tests + */ + setItem = (key: string, value: any): Promise => { + if (isReactNative) { + return RNSecureKeyStore.set(key, value, { accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + } else { + return AsyncStorage.setItem(key, value); + } + }; + + /** + * Wrapper for storage call. Secure store works only in RN environment. AsyncStorage is + * used for cli/tests + */ + getItem = (key: string): Promise => { + if (isReactNative) { + return RNSecureKeyStore.get(key); + } else { + return AsyncStorage.getItem(key); + } + }; + + getItemWithFallbackToRealm = async (key: string): Promise => { + let value; + try { + return await this.getItem(key); + } catch (error: any) { + console.warn('error reading', key, error.message); + console.warn('fallback to realm'); + const realmKeyValue = await this.openRealmKeyValue(); + const obj = realmKeyValue.objectForPrimaryKey<{ key: string; value: string }>('KeyValue', key); + value = obj?.value; + realmKeyValue.close(); + if (value) { + console.warn('successfully recovered', value.length, 'bytes from realm for key', key); + return value; + } + return null; + } + }; + + storageIsEncrypted = async (): Promise => { + let data; + try { + data = await this.getItemWithFallbackToRealm(BlueApp.FLAG_ENCRYPTED); + } catch (error: any) { + console.warn('error reading `' + BlueApp.FLAG_ENCRYPTED + '` key:', error.message); + return false; + } + + return Boolean(data); + }; + + isPasswordInUse = async (password: string) => { + try { + let data = await this.getItem('data'); + data = this.decryptData(data, password); + return Boolean(data); + } catch (_e) { + return false; + } + }; + + /** + * Iterates through all values of `data` trying to + * decrypt each one, and returns first one successfully decrypted + */ + decryptData(data: string, password: string): boolean | string { + data = JSON.parse(data); + let decrypted; + let num = 0; + for (const value of data) { + decrypted = encryption.decrypt(value, password); + + if (decrypted) { + usedBucketNum = num; + return decrypted; + } + num++; + } + + return false; + } + + decryptStorage = async (password: string): Promise => { + if (password === this.cachedPassword) { + this.cachedPassword = undefined; + await this.saveToDisk(); + this.wallets = []; + this.tx_metadata = {}; + this.counterparty_metadata = {}; + return this.loadFromDisk(); + } else { + throw new Error('Incorrect password. Please, try again.'); + } + }; + + encryptStorage = async (password: string): Promise => { + // assuming the storage is not yet encrypted + await this.saveToDisk(); + let data = await this.getItem('data'); + // TODO: refactor ^^^ (should not save & load to fetch data) + + const encrypted = encryption.encrypt(data, password); + data = []; + data.push(encrypted); // putting in array as we might have many buckets with storages + data = JSON.stringify(data); + this.cachedPassword = password; + await this.setItem('data', data); + await this.setItem(BlueApp.FLAG_ENCRYPTED, '1'); + }; + + /** + * Cleans up all current application data (wallets, tx metadata etc) + * Encrypts the bucket and saves it storage + */ + createFakeStorage = async (fakePassword: string): Promise => { + usedBucketNum = false; // resetting currently used bucket so we wont overwrite it + this.wallets = []; + this.tx_metadata = {}; + this.counterparty_metadata = {}; + + const data: TBucketStorage = { + wallets: [], + tx_metadata: {}, + counterparty_metadata: {}, + }; + + let buckets = await this.getItem('data'); + buckets = JSON.parse(buckets); + buckets.push(encryption.encrypt(JSON.stringify(data), fakePassword)); + this.cachedPassword = fakePassword; + const bucketsString = JSON.stringify(buckets); + await this.setItem('data', bucketsString); + return (await this.getItem('data')) === bucketsString; + }; + + hashIt = (s: string): string => { + return uint8ArrayToHex(sha256(s)); + }; + + /** + * Returns instace of the Realm database, which is encrypted either by cached user's password OR default password. + * Database file is deterministically derived from encryption key. + */ + async getRealmForTransactions() { + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder + const password = this.hashIt(this.cachedPassword || 'fyegjitkyf[eqjnc.lf'); + const buf = hexToUint8Array(this.hashIt(password) + this.hashIt(password)); + const encryptionKey = Int8Array.from(buf); + const fileName = this.hashIt(this.hashIt(password)) + '-wallettransactions.realm'; + const path = `${cacheFolderPath}/${fileName}`; // Use cache folder path + + const schema = [ + { + name: 'WalletTransactions', + properties: { + walletid: { type: 'string', indexed: true }, + internal: 'bool?', // true - internal, false - external + index: 'int?', + tx: 'string', // stringified json + }, + }, + ]; + // @ts-ignore schema doesn't match Realm's schema type + return Realm.open({ + // @ts-ignore schema doesn't match Realm's schema type + schema, + path, + encryptionKey, + excludeFromIcloudBackup: true, + }); + } + + /** + * Returns instace of the Realm database, which is encrypted by random bytes stored in keychain. + * Database file is static. + * + * @returns {Promise} + */ + async openRealmKeyValue(): Promise { + const cacheFolderPath = RNFS.CachesDirectoryPath; // Path to cache folder + const service = 'realm_encryption_key'; + let password; + const credentials = await Keychain.getGenericPassword({ service }); + if (credentials) { + password = credentials.password; + } else { + const buf = await randomBytes(64); + password = uint8ArrayToHex(buf); + await Keychain.setGenericPassword(service, password, { service }); + } + + const buf = hexToUint8Array(password); + const encryptionKey = Int8Array.from(buf); + const path = `${cacheFolderPath}/keyvalue.realm`; // Use cache folder path + + const schema = [ + { + name: 'KeyValue', + primaryKey: 'key', + properties: { + key: { type: 'string', indexed: true }, + value: 'string', // stringified json, or whatever + }, + }, + ]; + // @ts-ignore schema doesn't match Realm's schema type + return Realm.open({ + // @ts-ignore schema doesn't match Realm's schema type + schema, + path, + encryptionKey, + excludeFromIcloudBackup: true, + }); + } + + saveToRealmKeyValue(realmkeyValue: Realm, key: string, value: any) { + realmkeyValue.write(() => { + realmkeyValue.create( + 'KeyValue', + { + key, + value, + }, + Realm.UpdateMode.Modified, + ); + }); + } + + /** + * Loads from storage all wallets and + * maps them to `this.wallets` + * + * @param password If present means storage must be decrypted before usage + * @returns {Promise.} + */ + async loadFromDisk(password?: string): Promise { + // Wrap inside a try so if anything goes wrong it wont block loadFromDisk from continuing + try { + await this.moveRealmFilesToCacheDirectory(); + } catch (error: any) { + console.warn('moveRealmFilesToCacheDirectory error:', error.message); + } + let dataRaw = await this.getItemWithFallbackToRealm('data'); + if (password) { + dataRaw = this.decryptData(dataRaw, password); + if (dataRaw) { + // password is good, cache it + this.cachedPassword = password; + } + } + if (dataRaw !== null) { + let realm; + try { + realm = await this.getRealmForTransactions(); + } catch (error: any) { + presentAlert({ message: error.message }); + } + const data: TBucketStorage = JSON.parse(dataRaw); + if (!data.wallets) return false; + const wallets = data.wallets; + for (const key of wallets) { + // deciding which type is wallet and instantiating correct object + const tempObj = JSON.parse(key); + let unserializedWallet: TWallet; + switch (tempObj.type) { + case SegwitBech32Wallet.type: + unserializedWallet = SegwitBech32Wallet.fromJson(key) as unknown as SegwitBech32Wallet; + break; + case SegwitP2SHWallet.type: + unserializedWallet = SegwitP2SHWallet.fromJson(key) as unknown as SegwitP2SHWallet; + break; + case WatchOnlyWallet.type: + unserializedWallet = WatchOnlyWallet.fromJson(key) as unknown as WatchOnlyWallet; + unserializedWallet.init(); + if (unserializedWallet.isHd() && !unserializedWallet.isXpubValid()) { + continue; + } + break; + case HDLegacyP2PKHWallet.type: + unserializedWallet = HDLegacyP2PKHWallet.fromJson(key) as unknown as HDLegacyP2PKHWallet; + break; + case HDSegwitP2SHWallet.type: + unserializedWallet = HDSegwitP2SHWallet.fromJson(key) as unknown as HDSegwitP2SHWallet; + break; + case HDSegwitBech32Wallet.type: + unserializedWallet = HDSegwitBech32Wallet.fromJson(key) as unknown as HDSegwitBech32Wallet; + break; + case HDTaprootWallet.type: + unserializedWallet = HDTaprootWallet.fromJson(key) as unknown as HDTaprootWallet; + break; + case HDLegacyBreadwalletWallet.type: + unserializedWallet = HDLegacyBreadwalletWallet.fromJson(key) as unknown as HDLegacyBreadwalletWallet; + break; + case HDLegacyElectrumSeedP2PKHWallet.type: + unserializedWallet = HDLegacyElectrumSeedP2PKHWallet.fromJson(key) as unknown as HDLegacyElectrumSeedP2PKHWallet; + break; + case HDSegwitElectrumSeedP2WPKHWallet.type: + unserializedWallet = HDSegwitElectrumSeedP2WPKHWallet.fromJson(key) as unknown as HDSegwitElectrumSeedP2WPKHWallet; + break; + case MultisigHDWallet.type: + unserializedWallet = MultisigHDWallet.fromJson(key) as unknown as MultisigHDWallet; + break; + case HDAezeedWallet.type: + unserializedWallet = HDAezeedWallet.fromJson(key) as unknown as HDAezeedWallet; + // migrate password to this.passphrase field + // remove this code somewhere in year 2022 + if (unserializedWallet.secret.includes(':')) { + const [mnemonic, passphrase] = unserializedWallet.secret.split(':'); + unserializedWallet.secret = mnemonic; + unserializedWallet.passphrase = passphrase; + } + + break; + case SLIP39SegwitP2SHWallet.type: + unserializedWallet = SLIP39SegwitP2SHWallet.fromJson(key) as unknown as SLIP39SegwitP2SHWallet; + break; + case SLIP39LegacyP2PKHWallet.type: + unserializedWallet = SLIP39LegacyP2PKHWallet.fromJson(key) as unknown as SLIP39LegacyP2PKHWallet; + break; + case SLIP39SegwitBech32Wallet.type: + unserializedWallet = SLIP39SegwitBech32Wallet.fromJson(key) as unknown as SLIP39SegwitBech32Wallet; + break; + case LightningArkWallet.type: + unserializedWallet = LightningArkWallet.fromJson(key) as unknown as LightningArkWallet; + break; + case LightningCustodianWallet.type: { + unserializedWallet = LightningCustodianWallet.fromJson(key) as unknown as LightningCustodianWallet; + let lndhub: false | any = false; + try { + lndhub = await getLNDHub(); + } catch (error) { + console.warn(error); + } + + if (unserializedWallet.baseURI) { + unserializedWallet.setBaseURI(unserializedWallet.baseURI); // not really necessary, just for the sake of readability + console.log('using saved uri for for ln wallet:', unserializedWallet.baseURI); + } else if (lndhub) { + console.log('using wallet-wide settings ', lndhub, 'for ln wallet'); + unserializedWallet.setBaseURI(lndhub); + } else { + console.log('wallet does not have a baseURI. Continuing init...'); + } + unserializedWallet.init(); + break; + } + case 'lightningLdk': + // since ldk wallets are deprecated and removed, we need to handle a case when such wallet still exists in storage + unserializedWallet = new HDSegwitBech32Wallet(); + unserializedWallet.setSecret(tempObj.secret.replace('ldk://', '')); + break; + case LegacyWallet.type: + default: + unserializedWallet = LegacyWallet.fromJson(key) as unknown as LegacyWallet; + break; + } + + try { + if (realm) this.inflateWalletFromRealm(realm, unserializedWallet); + } catch (error: any) { + presentAlert({ message: error.message }); + } + + // done + const ID = unserializedWallet.getID(); + if (!this.wallets.some(wallet => wallet.getID() === ID)) { + this.wallets.push(unserializedWallet); + this.tx_metadata = data.tx_metadata; + this.counterparty_metadata = data.counterparty_metadata; + } + } + if (realm) realm.close(); + return true; + } else { + return false; // failed loading data or loading/decryptin data + } + } + + /** + * Lookup wallet in list by it's secret and + * remove it from `this.wallets` + * + * @param wallet {AbstractWallet} + */ + deleteWallet = (wallet: TWallet): void => { + const ID = wallet.getID(); + const tempWallets = []; + + for (const value of this.wallets) { + if (value.getID() === ID) { + // the one we should delete + // nop + } else { + // the one we must keep + tempWallets.push(value); + } + } + this.wallets = tempWallets; + }; + + inflateWalletFromRealm(realm: Realm, walletToInflate: TWallet) { + const transactions = realm.objects('WalletTransactions'); + const transactionsForWallet = transactions.filtered(`walletid = "${walletToInflate.getID()}"`) as unknown as TRealmTransaction[]; + for (const tx of transactionsForWallet) { + if (tx.internal === false) { + if ('_hdWalletInstance' in walletToInflate && walletToInflate._hdWalletInstance) { + const hd = walletToInflate._hdWalletInstance; + hd._txs_by_external_index[tx.index] = hd._txs_by_external_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + hd._txs_by_external_index[tx.index].push(transaction); + } else { + walletToInflate._txs_by_external_index[tx.index] = walletToInflate._txs_by_external_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + (walletToInflate._txs_by_external_index[tx.index] as Transaction[]).push(transaction); + } + } else if (tx.internal === true) { + if ('_hdWalletInstance' in walletToInflate && walletToInflate._hdWalletInstance) { + const hd = walletToInflate._hdWalletInstance; + hd._txs_by_internal_index[tx.index] = hd._txs_by_internal_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + hd._txs_by_internal_index[tx.index].push(transaction); + } else { + walletToInflate._txs_by_internal_index[tx.index] = walletToInflate._txs_by_internal_index[tx.index] || []; + const transaction = JSON.parse(tx.tx); + (walletToInflate._txs_by_internal_index[tx.index] as Transaction[]).push(transaction); + } + } else { + // Legacy single-address wallets - store under index 0 + walletToInflate._txs_by_external_index = walletToInflate._txs_by_external_index || {}; + walletToInflate._txs_by_external_index[0] = walletToInflate._txs_by_external_index[0] || []; + const transaction = JSON.parse(tx.tx); + walletToInflate._txs_by_external_index[0].push(transaction); + } + } + } + + offloadWalletToRealm(realm: Realm, wallet: TWallet): void { + const id = wallet.getID(); + const walletToSave = ('_hdWalletInstance' in wallet && wallet._hdWalletInstance) || wallet; + + if (walletToSave._txs_by_external_index) { + realm.write(() => { + // cleanup all existing transactions for the wallet first + const walletTransactionsToDelete = realm.objects('WalletTransactions').filtered(`walletid = '${id}'`); + realm.delete(walletTransactionsToDelete); + + // insert new ones: + for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_external_index)) { + for (const tx of txs) { + realm.create( + 'WalletTransactions', + { + walletid: id, + internal: false, + index: parseInt(indexStr, 10), + tx: JSON.stringify(tx), + }, + Realm.UpdateMode.Modified, + ); + } + } + + for (const [indexStr, txs] of Object.entries(walletToSave._txs_by_internal_index)) { + for (const tx of txs) { + realm.create( + 'WalletTransactions', + { + walletid: id, + internal: true, + index: parseInt(indexStr, 10), + tx: JSON.stringify(tx), + }, + Realm.UpdateMode.Modified, + ); + } + } + }); + } + } + + /** + * Serializes and saves to storage object data. + * If cached password is saved - finds the correct bucket + * to save to, encrypts and then saves. + * + * @returns {Promise} Result of storage save + */ + async saveToDisk(): Promise { + if (savingInProgress) { + console.warn('saveToDisk is in progress'); + if (++savingInProgress > 10) presentAlert({ message: 'Critical error. Last actions were not saved' }); // should never happen + await new Promise(resolve => setTimeout(resolve, 1000 * savingInProgress)); // sleep + return this.saveToDisk(); + } + savingInProgress = 1; + + try { + const walletsToSave: string[] = []; // serialized wallets + let realm; + try { + realm = await this.getRealmForTransactions(); + } catch (error: any) { + presentAlert({ message: error.message }); + } + for (const key of this.wallets) { + if (typeof key === 'boolean') continue; + key.prepareForSerialization(); + // @ts-ignore wtf is wallet.current? Does it even exist? + delete key.current; + const keyCloned = Object.assign({}, key); // stripped-down version of a wallet to save to secure keystore + if ('_hdWalletInstance' in key) { + const k = keyCloned as any & WatchOnlyWallet; + k._hdWalletInstance = Object.assign({}, key._hdWalletInstance); + k._hdWalletInstance._txs_by_external_index = {}; + k._hdWalletInstance._txs_by_internal_index = {}; + } + if (realm) this.offloadWalletToRealm(realm, key); + // stripping down: + if (key._txs_by_external_index) { + keyCloned._txs_by_external_index = {}; + keyCloned._txs_by_internal_index = {}; + } + + if ('_bip47_instance' in keyCloned) { + delete keyCloned._bip47_instance; // since it wont be restored into a proper class instance + } + + walletsToSave.push(JSON.stringify({ ...keyCloned, type: keyCloned.type })); + } + if (realm) realm.close(); + + let data: TBucketStorage | string[] /* either a bucket, or an array of encrypted buckets */ = { + wallets: walletsToSave, + tx_metadata: this.tx_metadata, + counterparty_metadata: this.counterparty_metadata, + }; + + if (this.cachedPassword) { + // should find the correct bucket, encrypt and then save + let buckets = await this.getItemWithFallbackToRealm('data'); + buckets = JSON.parse(buckets); + const newData: string[] = []; // serialized buckets + let num = 0; + for (const bucket of buckets) { + let decrypted; + // if we had `usedBucketNum` during loadFromDisk(), no point to try to decode each bucket to find the one we + // need, we just to find bucket with the same index + if (usedBucketNum !== false) { + if (num === usedBucketNum) { + decrypted = true; + } + num++; + } else { + // we dont have `usedBucketNum` for whatever reason, so lets try to decrypt each bucket after bucket + // till we find the right one + decrypted = encryption.decrypt(bucket, this.cachedPassword); + } + + if (!decrypted) { + // no luck decrypting, its not our bucket + newData.push(bucket); + } else { + // decrypted ok, this is our bucket + // we serialize our object's data, encrypt it, and add it to buckets + newData.push(encryption.encrypt(JSON.stringify(data), this.cachedPassword)); + } + } + + data = newData; + } + + await this.setItem('data', JSON.stringify(data)); + await this.setItem(BlueApp.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); + + // now, backing up same data in realm: + const realmkeyValue = await this.openRealmKeyValue(); + this.saveToRealmKeyValue(realmkeyValue, 'data', JSON.stringify(data)); + this.saveToRealmKeyValue(realmkeyValue, BlueApp.FLAG_ENCRYPTED, this.cachedPassword ? '1' : ''); + realmkeyValue.close(); + } catch (error: any) { + console.error('save to disk exception:', error.message); + presentAlert({ message: 'save to disk exception: ' + error.message }); + if (error.message.includes('Realm file decryption failed')) { + console.warn('purging realm key-value database file'); + this.purgeRealmKeyValueFile(); + } + } finally { + savingInProgress = 0; + } + } + + /** + * For each wallet, fetches balance from remote endpoint. + * Use getter for a specific wallet to get actual balance. + * Returns void. + * If index is present then fetch only from this specific wallet + */ + fetchWalletBalances = async (index?: number): Promise => { + console.log('fetchWalletBalances for wallet#', typeof index === 'undefined' ? '(all)' : index); + if (index || index === 0) { + let c = 0; + for (const wallet of this.wallets) { + if (c++ === index) { + await wallet.fetchBalance(); + } + } + } else { + await Promise.all( + this.wallets.map(async wallet => { + console.log('fetching balance for', wallet.getLabel()); + await wallet.fetchBalance(); + }), + ); + } + }; + + /** + * Fetches from remote endpoint all transactions for each wallet. + * Returns void. + * To access transactions - get them from each respective wallet. + * If index is present then fetch only from this specific wallet. + * + * @param index {Integer} Index of the wallet in this.wallets array, + * blank to fetch from all wallets + * @return {Promise.} + */ + fetchWalletTransactions = async (index?: number) => { + console.log('fetchWalletTransactions for wallet#', typeof index === 'undefined' ? '(all)' : index); + if (index || index === 0) { + let c = 0; + for (const wallet of this.wallets) { + if (c++ === index) { + await wallet.fetchTransactions(); + + if ('fetchPendingTransactions' in wallet) { + await wallet.fetchPendingTransactions(); + await wallet.fetchUserInvoices(); + } + } + } + } else { + await Promise.all( + this.wallets.map(async wallet => { + await wallet.fetchTransactions(); + if ('fetchPendingTransactions' in wallet) { + await wallet.fetchPendingTransactions(); + await wallet.fetchUserInvoices(); + } + }), + ); + } + }; + + fetchSenderPaymentCodes = async (index?: number) => { + console.log('fetchSenderPaymentCodes for wallet#', typeof index === 'undefined' ? '(all)' : index); + if (index || index === 0) { + const wallet = this.wallets[index]; + try { + if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return; + await wallet.fetchBIP47SenderPaymentCodes(); + } catch (error) { + console.error('Failed to fetch sender payment codes for wallet', index, error); + } + } else { + await Promise.all( + this.wallets.map(async wallet => { + try { + if (!(wallet.allowBIP47() && wallet.isBIP47Enabled() && 'fetchBIP47SenderPaymentCodes' in wallet)) return; + await wallet.fetchBIP47SenderPaymentCodes(); + } catch (error) { + console.error('Failed to fetch sender payment codes for wallet', wallet.label, error); + } + }), + ); + } + }; + + getWallets = (): TWallet[] => { + return this.wallets; + }; + + /** + * Getter for all transactions in all wallets. + * But if index is provided - only for wallet with corresponding index + * + * @param index {number|undefined} Wallet index in this.wallets. Empty (or undef) for all wallets. + * @param limit {number} How many txs return, starting from the earliest. Default: all of them. + * @param includeWalletsWithHideTransactionsEnabled {boolean} Wallets' _hideTransactionsInWalletsList property determines wether the user wants this wallet's txs hidden from the main list view. + */ + getTransactions = ( + index?: number, + limit: number = Infinity, + includeWalletsWithHideTransactionsEnabled: boolean = false, + ): ExtendedTransaction[] => { + if (index || index === 0) { + let txs: Transaction[] = []; + let c = 0; + for (const wallet of this.wallets) { + if (c++ === index) { + txs = txs.concat(wallet.getTransactions()); + + const txsRet: ExtendedTransaction[] = []; + const walletID = wallet.getID(); + const walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); + txs.map(tx => + txsRet.push({ + ...tx, + walletID, + walletPreferredBalanceUnit, + }), + ); + return txsRet; + } + } + } + + const txs: ExtendedTransaction[] = []; + for (const wallet of this.wallets.filter(w => includeWalletsWithHideTransactionsEnabled || !w.getHideTransactionsInWalletsList())) { + const walletTransactions: Transaction[] = wallet.getTransactions(); + const walletID = wallet.getID(); + const walletPreferredBalanceUnit = wallet.getPreferredBalanceUnit(); + for (const t of walletTransactions) { + txs.push({ + ...t, + walletID, + walletPreferredBalanceUnit, + }); + } + } + + return txs + .sort((a, b) => { + return b.timestamp - a.timestamp; + }) + .slice(0, limit); + }; + + /** + * Getter for a sum of all balances of all wallets + */ + getBalance = (): number => { + let finalBalance = 0; + for (const wal of this.wallets) { + finalBalance += wal.getBalance(); + } + return finalBalance; + }; + + isHandoffEnabled = async (): Promise => { + try { + return !!(await AsyncStorage.getItem(BlueApp.HANDOFF_STORAGE_KEY)); + } catch (_) {} + return false; + }; + + setIsHandoffEnabled = async (value: boolean): Promise => { + await AsyncStorage.setItem(BlueApp.HANDOFF_STORAGE_KEY, value ? '1' : ''); + }; + + isDoNotTrackEnabled = async (): Promise => { + try { + const keyExists = await AsyncStorage.getItem(BlueApp.DO_NOT_TRACK); + if (keyExists !== null) { + const doNotTrackValue = !!keyExists; + if (doNotTrackValue) { + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + AsyncStorage.removeItem(BlueApp.DO_NOT_TRACK); + } else { + return Boolean(await DefaultPreference.get(BlueApp.DO_NOT_TRACK)); + } + } + } catch (_) {} + const doNotTrackValue = await DefaultPreference.get(BlueApp.DO_NOT_TRACK); + return doNotTrackValue === '1' || false; + }; + + setDoNotTrack = async (value: boolean) => { + if (value) { + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + } else { + await DefaultPreference.clear(BlueApp.DO_NOT_TRACK); + } + }; + + /** + * Simple async sleeper function + */ + sleep = (ms: number): Promise => { + return new Promise(resolve => setTimeout(resolve, ms)); + }; + + purgeRealmKeyValueFile() { + const path = 'keyvalue.realm'; + return Realm.deleteFile({ + path, + }); + } + + async moveRealmFilesToCacheDirectory() { + const documentPath = RNFS.DocumentDirectoryPath; // Path to documentPath folder + const cachePath = RNFS.CachesDirectoryPath; // Path to cachePath folder + try { + if (!(await RNFS.exists(documentPath))) return; // If the documentPath directory does not exist, return (nothing to move) + const files = await RNFS.readDir(documentPath); // Read all files in documentPath directory + if (Array.isArray(files) && files.length === 0) return; // If there are no files, return (nothing to move) + const appRealmFiles = files.filter( + file => file.name.endsWith('.realm') || file.name.endsWith('.realm.lock') || file.name.includes('.realm.management'), + ); + + for (const file of appRealmFiles) { + const filePath = `${documentPath}/${file.name}`; + const newFilePath = `${cachePath}/${file.name}`; + const fileExists = await RNFS.exists(filePath); // Check if the file exists + const cacheFileExists = await RNFS.exists(newFilePath); // Check if the file already exists in the cache directory + + if (fileExists) { + if (cacheFileExists) { + await RNFS.unlink(newFilePath); // Delete the file in the cache directory if it exists + console.log(`Existing file removed from cache: ${newFilePath}`); + } + await RNFS.moveFile(filePath, newFilePath); // Move the file + console.log(`Moved Realm file: ${filePath} to ${newFilePath}`); + } else { + console.log(`File does not exist: ${filePath}`); + } + } + } catch (error) { + console.error('Error moving Realm files:', error); + throw new Error(`Error moving Realm files: ${(error as Error).message}`); + } + } +} diff --git a/class/camera.js b/class/camera.js deleted file mode 100644 index d6baab18041..00000000000 --- a/class/camera.js +++ /dev/null @@ -1,33 +0,0 @@ -import { Linking, Alert } from 'react-native'; -import { getSystemName } from 'react-native-device-info'; -import loc from '../loc'; - -const isDesktop = getSystemName() === 'Mac OS X'; - -export const openPrivacyDesktopSettings = () => { - if (isDesktop) { - Linking.openURL('x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'); - } else { - Linking.openSettings(); - } -}; - -export const presentCameraNotAuthorizedAlert = error => { - Alert.alert( - loc.errors.error, - error, - [ - { - text: loc.send.open_settings, - onPress: openPrivacyDesktopSettings, - style: 'default', - }, - { - text: loc._.ok, - onPress: () => {}, - style: 'cancel', - }, - ], - { cancelable: true }, - ); -}; diff --git a/class/camera.ts b/class/camera.ts new file mode 100644 index 00000000000..e9c5116796f --- /dev/null +++ b/class/camera.ts @@ -0,0 +1,32 @@ +import { Alert, Linking } from 'react-native'; + +import { isDesktop } from '../blue_modules/environment'; +import loc from '../loc'; + +export const openPrivacyDesktopSettings = () => { + if (isDesktop) { + Linking.openURL('x-apple.systempreferences:com.apple.preference.security?Privacy_Camera'); + } else { + Linking.openSettings(); + } +}; + +export const presentCameraNotAuthorizedAlert = (error: string) => { + Alert.alert( + loc.errors.error, + error, + [ + { + text: loc.send.open_settings, + onPress: openPrivacyDesktopSettings, + style: 'default', + }, + { + text: loc._.ok, + onPress: () => {}, + style: 'cancel', + }, + ], + { cancelable: true }, + ); +}; diff --git a/class/contact-list.ts b/class/contact-list.ts new file mode 100644 index 00000000000..28fa673d42c --- /dev/null +++ b/class/contact-list.ts @@ -0,0 +1,43 @@ +import BIP47Factory from '@spsina/bip47'; + +import { SilentPayment } from 'silent-payments'; + +import ecc from '../blue_modules/noble_ecc'; +import { concatUint8Arrays } from '../blue_modules/uint8array-extras'; +import * as bitcoin from 'bitcoinjs-lib'; + +export class ContactList { + isBip47PaymentCodeValid(pc: string) { + try { + BIP47Factory(ecc).fromPaymentCode(pc); + return true; + } catch (_) { + return false; + } + } + + isBip352PaymentCodeValid(pc: string) { + return SilentPayment.isPaymentCodeValid(pc); + } + + isPaymentCodeValid(pc: string): boolean { + return this.isBip47PaymentCodeValid(pc) || this.isBip352PaymentCodeValid(pc); + } + + isAddressValid(address: string): boolean { + try { + bitcoin.address.toOutputScript(address); // throws, no? + + if (!address.toLowerCase().startsWith('bc1')) return true; + const decoded = bitcoin.address.fromBech32(address); + if (decoded.version === 0) return true; + if (decoded.version === 1 && decoded.data.length !== 32) return false; + if (decoded.version === 1 && !ecc.isPoint(concatUint8Arrays([new Uint8Array([2]), decoded.data]))) return false; + if (decoded.version > 1) return false; + // ^^^ some day, when versions above 1 will be actually utilized, we would need to unhardcode this + return true; + } catch (e) { + return false; + } + } +} diff --git a/class/deeplink-schema-match.js b/class/deeplink-schema-match.js deleted file mode 100644 index eb1fd38e76b..00000000000 --- a/class/deeplink-schema-match.js +++ /dev/null @@ -1,465 +0,0 @@ -import { LightningCustodianWallet, WatchOnlyWallet } from './'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import RNFS from 'react-native-fs'; -import URL from 'url'; -import { Chain } from '../models/bitcoinUnits'; -import Lnurl from './lnurl'; -import Azteco from './azteco'; -const bitcoin = require('bitcoinjs-lib'); -const bip21 = require('bip21'); -const BlueApp = require('../BlueApp'); -const AppStorage = BlueApp.AppStorage; - -class DeeplinkSchemaMatch { - static hasSchema(schemaString) { - if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; - const lowercaseString = schemaString.trim().toLowerCase(); - return ( - lowercaseString.startsWith('bitcoin:') || - lowercaseString.startsWith('lightning:') || - lowercaseString.startsWith('blue:') || - lowercaseString.startsWith('bluewallet:') || - lowercaseString.startsWith('lapp:') - ); - } - - /** - * Examines the content of the event parameter. - * If the content is recognizable, create a dictionary with the respective - * navigation dictionary required by react-navigation - * - * @param event {{url: string}} URL deeplink as passed to app, e.g. `bitcoin:bc1qh6tf004ty7z7un2v5ntu4mkf630545gvhs45u7?amount=666&label=Yo` - * @param completionHandler {function} Callback that returns [string, params: object] - */ - static navigationRouteFor(event, completionHandler, context = { wallets: [], saveToDisk: () => {}, addWallet: () => {} }) { - if (event.url === null) { - return; - } - if (typeof event.url !== 'string') { - return; - } - - if (event.url.toLowerCase().startsWith('bluewallet:bitcoin:') || event.url.toLowerCase().startsWith('bluewallet:lightning:')) { - event.url = event.url.substring(11); - } else if (event.url.toLocaleLowerCase().startsWith('bluewallet://widget?action=')) { - event.url = event.url.substring('bluewallet://'.length); - } - - if (DeeplinkSchemaMatch.isWidgetAction(event.url)) { - if (context.wallets.length >= 0) { - const wallet = context.wallets[0]; - const action = event.url.split('widget?action=')[1]; - if (wallet.chain === Chain.ONCHAIN) { - if (action === 'openSend') { - completionHandler([ - 'SendDetailsRoot', - { - screen: 'SendDetails', - params: { - walletID: wallet.getID(), - }, - }, - ]); - } else if (action === 'openReceive') { - completionHandler([ - 'ReceiveDetailsRoot', - { - screen: 'ReceiveDetails', - params: { - walletID: wallet.getID(), - }, - }, - ]); - } - } else if (wallet.chain === Chain.OFFCHAIN) { - if (action === 'openSend') { - completionHandler([ - 'ScanLndInvoiceRoot', - { - screen: 'ScanLndInvoice', - params: { - walletID: wallet.getID(), - }, - }, - ]); - } else if (action === 'openReceive') { - completionHandler(['LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID: wallet.getID() } }]); - } - } - } - } else if (DeeplinkSchemaMatch.isPossiblySignedPSBTFile(event.url)) { - RNFS.readFile(decodeURI(event.url)) - .then(file => { - if (file) { - completionHandler([ - 'SendDetailsRoot', - { - screen: 'PsbtWithHardwareWallet', - params: { - deepLinkPSBT: file, - }, - }, - ]); - } - }) - .catch(e => console.warn(e)); - return; - } - let isBothBitcoinAndLightning; - try { - isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(event.url); - } catch (e) { - console.log(e); - } - if (isBothBitcoinAndLightning) { - completionHandler([ - 'SelectWallet', - { - onWalletSelect: (wallet, { navigation }) => { - navigation.pop(); // close select wallet screen - navigation.navigate(...DeeplinkSchemaMatch.isBothBitcoinAndLightningOnWalletSelect(wallet, isBothBitcoinAndLightning)); - }, - }, - ]); - } else if (DeeplinkSchemaMatch.isBitcoinAddress(event.url)) { - completionHandler([ - 'SendDetailsRoot', - { - screen: 'SendDetails', - params: { - uri: event.url.replace('://', ':'), - }, - }, - ]); - } else if (DeeplinkSchemaMatch.isLightningInvoice(event.url)) { - completionHandler([ - 'ScanLndInvoiceRoot', - { - screen: 'ScanLndInvoice', - params: { - uri: event.url.replace('://', ':'), - }, - }, - ]); - } else if (DeeplinkSchemaMatch.isLnUrl(event.url)) { - // at this point we can not tell if it is lnurl-pay or lnurl-withdraw since it needs additional async call - // to the server, which is undesirable here, so LNDCreateInvoice screen will handle it for us and will - // redirect user to LnurlPay screen if necessary - completionHandler([ - 'LNDCreateInvoiceRoot', - { - screen: 'LNDCreateInvoice', - params: { - uri: event.url.replace('lightning:', '').replace('LIGHTNING:', ''), - }, - }, - ]); - } else if (Lnurl.isLightningAddress(event.url)) { - // this might be not just an email but a lightning addres - // @see https://lightningaddress.com - completionHandler([ - 'ScanLndInvoiceRoot', - { - screen: 'ScanLndInvoice', - params: { - uri: event.url, - }, - }, - ]); - } else if (Azteco.isRedeemUrl(event.url)) { - completionHandler([ - 'AztecoRedeemRoot', - { - screen: 'AztecoRedeem', - params: Azteco.getParamsFromUrl(event.url), - }, - ]); - } else if (new WatchOnlyWallet().setSecret(event.url).init().valid()) { - completionHandler([ - 'AddWalletRoot', - { - screen: 'ImportWallet', - params: { - triggerImport: true, - label: event.url, - }, - }, - ]); - } else { - const urlObject = URL.parse(event.url, true); // eslint-disable-line n/no-deprecated-api - (async () => { - if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { - switch (urlObject.host) { - case 'openlappbrowser': { - console.log('opening LAPP', urlObject.query.url); - // searching for LN wallet: - let haveLnWallet = false; - for (const w of context.wallets) { - if (w.type === LightningCustodianWallet.type) { - haveLnWallet = true; - } - } - - if (!haveLnWallet) { - // need to create one - const w = new LightningCustodianWallet(); - w.setLabel(w.typeReadable); - - try { - const lndhub = await AsyncStorage.getItem(AppStorage.LNDHUB); - if (lndhub) { - w.setBaseURI(lndhub); - w.init(); - } - await w.createAccount(); - await w.authorize(); - } catch (Err) { - // giving up, not doing anything - return; - } - context.addWallet(w); - context.saveToDisk(); - } - - // now, opening lapp browser and navigating it to URL. - // looking for a LN wallet: - let lnWallet; - for (const w of context.wallets) { - if (w.type === LightningCustodianWallet.type) { - lnWallet = w; - break; - } - } - - if (!lnWallet) { - // something went wrong - return; - } - - completionHandler([ - 'LappBrowserRoot', - { - screen: 'LappBrowser', - params: { - walletID: lnWallet.getID(), - url: urlObject.query.url, - }, - }, - ]); - break; - } - case 'setelectrumserver': - completionHandler([ - 'ElectrumSettings', - { - server: DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(event.url), - }, - ]); - break; - case 'setlndhuburl': - completionHandler([ - 'LightningSettings', - { - url: DeeplinkSchemaMatch.getUrlFromSetLndhubUrlAction(event.url), - }, - ]); - break; - } - } - })(); - } - } - - /** - * Extracts server from a deeplink like `bluewallet:setelectrumserver?server=electrum1.bluewallet.io%3A443%3As` - * returns FALSE if none found - * - * @param url {string} - * @return {string|boolean} - */ - static getServerFromSetElectrumServerAction(url) { - if (!url.startsWith('bluewallet:setelectrumserver') && !url.startsWith('setelectrumserver')) return false; - const splt = url.split('server='); - if (splt[1]) return decodeURIComponent(splt[1]); - return false; - } - - /** - * Extracts url from a deeplink like `bluewallet:setlndhuburl?url=https%3A%2F%2Flndhub.herokuapp.com` - * returns FALSE if none found - * - * @param url {string} - * @return {string|boolean} - */ - static getUrlFromSetLndhubUrlAction(url) { - if (!url.startsWith('bluewallet:setlndhuburl') && !url.startsWith('setlndhuburl')) return false; - const splt = url.split('url='); - if (splt[1]) return decodeURIComponent(splt[1]); - return false; - } - - static isTXNFile(filePath) { - return ( - (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && - filePath.toLowerCase().endsWith('.txn') - ); - } - - static isPossiblySignedPSBTFile(filePath) { - return ( - (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && - filePath.toLowerCase().endsWith('-signed.psbt') - ); - } - - static isPossiblyPSBTFile(filePath) { - return ( - (filePath.toLowerCase().startsWith('file:') || filePath.toLowerCase().startsWith('content:')) && - filePath.toLowerCase().endsWith('.psbt') - ); - } - - static isBothBitcoinAndLightningOnWalletSelect(wallet, uri) { - if (wallet.chain === Chain.ONCHAIN) { - return [ - 'SendDetailsRoot', - { - screen: 'SendDetails', - params: { - uri: uri.bitcoin, - walletID: wallet.getID(), - }, - }, - ]; - } else if (wallet.chain === Chain.OFFCHAIN) { - return [ - 'ScanLndInvoiceRoot', - { - screen: 'ScanLndInvoice', - params: { - uri: uri.lndInvoice, - walletID: wallet.getID(), - }, - }, - ]; - } - } - - static isBitcoinAddress(address) { - address = address.replace('://', ':').replace('bitcoin:', '').replace('BITCOIN:', '').replace('bitcoin=', '').split('?')[0]; - let isValidBitcoinAddress = false; - try { - bitcoin.address.toOutputScript(address); - isValidBitcoinAddress = true; - } catch (err) { - isValidBitcoinAddress = false; - } - return isValidBitcoinAddress; - } - - static isLightningInvoice(invoice) { - let isValidLightningInvoice = false; - if ( - invoice.toLowerCase().startsWith('lightning:lnb') || - invoice.toLowerCase().startsWith('lightning://lnb') || - invoice.toLowerCase().startsWith('lnb') - ) { - isValidLightningInvoice = true; - } - return isValidLightningInvoice; - } - - static isLnUrl(text) { - return Lnurl.isLnurl(text); - } - - static isWidgetAction(text) { - return text.startsWith('widget?action='); - } - - static isBothBitcoinAndLightning(url) { - if (url.includes('lightning') && (url.includes('bitcoin') || url.includes('BITCOIN'))) { - const txInfo = url.split(/(bitcoin:\/\/|BITCOIN:\/\/|bitcoin:|BITCOIN:|lightning:|lightning=|bitcoin=)+/); - let btc; - let lndInvoice; - for (const [index, value] of txInfo.entries()) { - try { - // Inside try-catch. We dont wan't to crash in case of an out-of-bounds error. - if (value.startsWith('bitcoin') || value.startsWith('BITCOIN')) { - btc = `bitcoin:${txInfo[index + 1]}`; - if (!DeeplinkSchemaMatch.isBitcoinAddress(btc)) { - btc = false; - break; - } - } else if (value.startsWith('lightning')) { - const lnpart = txInfo[index + 1].split('&').find(el => el.toLowerCase().startsWith('ln')); - lndInvoice = `lightning:${lnpart}`; - if (!this.isLightningInvoice(lndInvoice)) { - lndInvoice = false; - break; - } - } - } catch (e) { - console.log(e); - } - if (btc && lndInvoice) break; - } - if (btc && lndInvoice) { - return { bitcoin: btc, lndInvoice }; - } else { - return undefined; - } - } - return undefined; - } - - static bip21decode(uri) { - if (!uri) return {}; - let replacedUri = uri; - for (const replaceMe of ['BITCOIN://', 'bitcoin://', 'BITCOIN:']) { - replacedUri = replacedUri.replace(replaceMe, 'bitcoin:'); - } - - return bip21.decode(replacedUri); - } - - static bip21encode() { - const argumentsArray = Array.from(arguments); - for (const argument of argumentsArray) { - if (String(argument.label).replace(' ', '').length === 0) { - delete argument.label; - } - if (!(Number(argument.amount) > 0)) { - delete argument.amount; - } - } - return bip21.encode.apply(bip21, argumentsArray); - } - - static decodeBitcoinUri(uri) { - let amount = ''; - let parsedBitcoinUri = null; - let address = uri || ''; - let memo = ''; - let payjoinUrl = ''; - try { - parsedBitcoinUri = DeeplinkSchemaMatch.bip21decode(uri); - address = 'address' in parsedBitcoinUri ? parsedBitcoinUri.address : address; - if ('options' in parsedBitcoinUri) { - if ('amount' in parsedBitcoinUri.options) { - amount = parsedBitcoinUri.options.amount.toString(); - amount = parsedBitcoinUri.options.amount; - } - if ('label' in parsedBitcoinUri.options) { - memo = parsedBitcoinUri.options.label || memo; - } - if ('pj' in parsedBitcoinUri.options) { - payjoinUrl = parsedBitcoinUri.options.pj; - } - } - } catch (_) {} - return { address, amount, memo, payjoinUrl }; - } -} - -export default DeeplinkSchemaMatch; diff --git a/class/deeplink-schema-match.ts b/class/deeplink-schema-match.ts new file mode 100644 index 00000000000..214ae7907c7 --- /dev/null +++ b/class/deeplink-schema-match.ts @@ -0,0 +1,440 @@ +import bip21, { TOptions } from 'bip21'; +import * as bitcoin from 'bitcoinjs-lib'; +import URL from 'url'; +import { readFileOutsideSandbox } from '../blue_modules/fs'; +import { Chain } from '../models/bitcoinUnits'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; +import Azteco from './azteco'; +import Lnurl from './lnurl'; +import type { TWallet } from './wallets/types'; + +type TCompletionHandlerParams = [string, object]; +type TContext = { + wallets: TWallet[]; + saveToDisk: () => void; + addWallet: (wallet: TWallet) => void; + setSharedCosigner: (cosigner: string) => void; +}; + +type TBothBitcoinAndLightning = { bitcoin: string; lndInvoice: string } | undefined; + +class DeeplinkSchemaMatch { + static hasSchema(schemaString: string): boolean { + if (typeof schemaString !== 'string' || schemaString.length <= 0) return false; + const lowercaseString = schemaString.trim().toLowerCase(); + return ( + lowercaseString.startsWith('bitcoin:') || + lowercaseString.startsWith('lightning:') || + lowercaseString.startsWith('blue:') || + lowercaseString.startsWith('bluewallet:') || + lowercaseString.startsWith('lapp:') + ); + } + + /** + * Examines the content of the event parameter. + * If the content is recognizable, create a dictionary with the respective + * navigation dictionary required by react-navigation + * + * @param event {{url: string}} URL deeplink as passed to app, e.g. `bitcoin:bc1qh6tf004ty7z7un2v5ntu4mkf630545gvhs45u7?amount=666&label=Yo` + * @param completionHandler {function} Callback that returns [string, params: object] + */ + static navigationRouteFor( + event: { url: string }, + completionHandler: (args: TCompletionHandlerParams) => void, + context: TContext = { wallets: [], saveToDisk: () => {}, addWallet: () => {}, setSharedCosigner: () => {} }, + ) { + if (event.url === null) { + return; + } + if (typeof event.url !== 'string') { + return; + } + + if (event.url.toLowerCase().startsWith('bluewallet:bitcoin:') || event.url.toLowerCase().startsWith('bluewallet:lightning:')) { + event.url = event.url.substring(11); + } else if (event.url.toLocaleLowerCase().startsWith('bluewallet://widget?action=')) { + event.url = event.url.substring('bluewallet://'.length); + } + + if (DeeplinkSchemaMatch.isWidgetAction(event.url)) { + if (context.wallets.length >= 0) { + const wallet = context.wallets[0]; + const action = event.url.split('widget?action=')[1]; + if (wallet.chain === Chain.ONCHAIN) { + if (action === 'openSend') { + completionHandler([ + 'SendDetailsRoot', + { + screen: 'SendDetails', + params: { + walletID: wallet.getID(), + }, + }, + ]); + } else if (action === 'openReceive') { + completionHandler([ + 'DetailViewStackScreensStack', + { + screen: 'ReceiveDetails', + params: { + walletID: wallet.getID(), + }, + }, + ]); + } + } else if (wallet.chain === Chain.OFFCHAIN) { + if (action === 'openSend') { + completionHandler([ + 'ScanLNDInvoiceRoot', + { + screen: 'ScanLNDInvoice', + params: { + walletID: wallet.getID(), + }, + }, + ]); + } else if (action === 'openReceive') { + completionHandler(['LNDCreateInvoiceRoot', { screen: 'LNDCreateInvoice', params: { walletID: wallet.getID() } }]); + } + } + } + } else if (DeeplinkSchemaMatch.isPossiblyPSBTFile(event.url)) { + readFileOutsideSandbox(decodeURI(event.url)) + .then(file => { + if (file) { + completionHandler([ + 'SendDetailsRoot', + { + screen: 'PsbtWithHardwareWallet', + params: { + deepLinkPSBT: file, + }, + }, + ]); + } + }) + .catch(e => console.warn(e)); + return; + } else if (DeeplinkSchemaMatch.isPossiblyCosignerFile(event.url)) { + readFileOutsideSandbox(decodeURI(event.url)) + .then(file => { + // checks whether the necessary json keys are present in order to set a cosigner, + // doesn't validate the values this happens later + if (!file || !this.hasNeededJsonKeysForMultiSigSharing(file)) { + return; + } + context.setSharedCosigner(file); + }) + .catch(e => console.warn(e)); + } + let isBothBitcoinAndLightning: TBothBitcoinAndLightning; + try { + isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(event.url); + } catch (e) { + console.log(e); + } + if (isBothBitcoinAndLightning) { + completionHandler([ + 'SelectWallet', + { + onWalletSelect: (wallet: TWallet, { navigation }: any) => { + navigation.pop(); // close select wallet screen + navigation.navigate(...DeeplinkSchemaMatch.isBothBitcoinAndLightningOnWalletSelect(wallet, isBothBitcoinAndLightning)); + }, + }, + ]); + } else if (DeeplinkSchemaMatch.isBitcoinAddress(event.url)) { + completionHandler([ + 'SendDetailsRoot', + { + screen: 'SendDetails', + params: { + uri: event.url.replace('://', ':'), + }, + }, + ]); + } else if (DeeplinkSchemaMatch.isLightningInvoice(event.url)) { + completionHandler([ + 'ScanLNDInvoiceRoot', + { + screen: 'ScanLNDInvoice', + params: { + uri: event.url.replace('://', ':'), + }, + }, + ]); + } else if (DeeplinkSchemaMatch.isLnUrl(event.url)) { + // at this point we can not tell if it is lnurl-pay or lnurl-withdraw since it needs additional async call + // to the server, which is undesirable here, so LNDCreateInvoice screen will handle it for us and will + // redirect user to LnurlPay screen if necessary + completionHandler([ + 'LNDCreateInvoiceRoot', + { + screen: 'LNDCreateInvoice', + params: { + uri: event.url.replace('lightning:', '').replace('LIGHTNING:', ''), + }, + }, + ]); + } else if (Lnurl.isLightningAddress(event.url)) { + // this might be not just an email but a lightning address + // @see https://lightningaddress.com + completionHandler([ + 'ScanLNDInvoiceRoot', + { + screen: 'ScanLNDInvoice', + params: { + uri: event.url, + }, + }, + ]); + } else if (Azteco.isRedeemUrl(event.url)) { + completionHandler([ + 'AztecoRedeemRoot', + { + screen: 'AztecoRedeem', + params: Azteco.getParamsFromUrl(event.url), + }, + ]); + } else if (new WatchOnlyWallet().setSecret(event.url).init().valid()) { + completionHandler([ + 'AddWalletRoot', + { + screen: 'ImportWallet', + params: { + triggerImport: true, + label: event.url, + }, + }, + ]); + } else { + const urlObject = URL.parse(event.url, true); // eslint-disable-line n/no-deprecated-api + (async () => { + if (urlObject.protocol === 'bluewallet:' || urlObject.protocol === 'lapp:' || urlObject.protocol === 'blue:') { + switch (urlObject.host) { + case 'setelectrumserver': + completionHandler([ + 'ElectrumSettings', + { + server: DeeplinkSchemaMatch.getServerFromSetElectrumServerAction(event.url), + }, + ]); + break; + case 'setlndhuburl': + completionHandler([ + 'LightningSettings', + { + url: DeeplinkSchemaMatch.getUrlFromSetLndhubUrlAction(event.url), + }, + ]); + break; + } + } + })(); + } + } + + /** + * Extracts server from a deeplink like `bluewallet:setelectrumserver?server=electrum1.bluewallet.io%3A443%3As` + * returns FALSE if none found + * + * @param url {string} + * @return {string|boolean} + */ + static getServerFromSetElectrumServerAction(url: string): string | false { + if (!url.startsWith('bluewallet:setelectrumserver') && !url.startsWith('setelectrumserver')) return false; + const splt = url.split('server='); + if (splt[1]) return decodeURIComponent(splt[1]); + return false; + } + + /** + * Extracts url from a deeplink like `bluewallet:setlndhuburl?url=https%3A%2F%2Flndhub.herokuapp.com` + * returns FALSE if none found + * + * @param url {string} + * @return {string|boolean} + */ + static getUrlFromSetLndhubUrlAction(url: string): string | false { + if (!url.startsWith('bluewallet:setlndhuburl') && !url.startsWith('setlndhuburl')) return false; + const splt = url.split('url='); + if (splt[1]) return decodeURIComponent(splt[1]); + return false; + } + + static isTXNFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.txn'); + } + + static isPossiblyPSBTFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.psbt'); + } + + static isPossiblyCosignerFile(filePath: string): boolean { + return filePath.toLowerCase().endsWith('.bwcosigner'); + } + + static isBothBitcoinAndLightningOnWalletSelect(wallet: TWallet, uri: any): TCompletionHandlerParams { + if (wallet.chain === Chain.ONCHAIN) { + return [ + 'SendDetailsRoot', + { + screen: 'SendDetails', + params: { + uri: uri.bitcoin, + walletID: wallet.getID(), + }, + }, + ]; + } else { + return [ + 'ScanLNDInvoiceRoot', + { + screen: 'ScanLNDInvoice', + params: { + uri: uri.lndInvoice, + walletID: wallet.getID(), + }, + }, + ]; + } + } + + static isBitcoinAddress(address: string): boolean { + address = address.replace('://', ':').replace('bitcoin:', '').replace('BITCOIN:', '').replace('bitcoin=', '').split('?')[0]; + let isValidBitcoinAddress = false; + try { + bitcoin.address.toOutputScript(address); + isValidBitcoinAddress = true; + } catch (err) { + isValidBitcoinAddress = false; + } + return isValidBitcoinAddress; + } + + static isLightningInvoice(invoice: string): boolean { + let isValidLightningInvoice = false; + if ( + invoice.toLowerCase().startsWith('lightning:lnb') || + invoice.toLowerCase().startsWith('lightning://lnb') || + invoice.toLowerCase().startsWith('lnb') + ) { + isValidLightningInvoice = true; + } + return isValidLightningInvoice; + } + + static isLnUrl(text: string): boolean { + return Lnurl.isLnurl(text); + } + + static isWidgetAction(text: string): boolean { + return text.startsWith('widget?action='); + } + + static hasNeededJsonKeysForMultiSigSharing(str: string): boolean { + let obj; + + // Check if it's a valid JSON + try { + obj = JSON.parse(str); + } catch (e) { + return false; + } + + // Check for the existence and type of the keys + return typeof obj.xfp === 'string' && typeof obj.xpub === 'string' && typeof obj.path === 'string'; + } + + static isBothBitcoinAndLightning(url: string): TBothBitcoinAndLightning { + if (url.includes('lightning') && (url.includes('bitcoin') || url.includes('BITCOIN'))) { + const txInfo = url.split(/(bitcoin:\/\/|BITCOIN:\/\/|bitcoin:|BITCOIN:|lightning:|lightning=|bitcoin=)+/); + let btc: string | false = false; + let lndInvoice: string | false = false; + for (const [index, value] of txInfo.entries()) { + try { + // Inside try-catch. We dont wan't to crash in case of an out-of-bounds error. + if (value.startsWith('bitcoin') || value.startsWith('BITCOIN')) { + btc = `bitcoin:${txInfo[index + 1]}`; + if (!DeeplinkSchemaMatch.isBitcoinAddress(btc)) { + btc = false; + break; + } + } else if (value.startsWith('lightning')) { + const lnpart = txInfo[index + 1].split('&').find(el => el.toLowerCase().startsWith('ln')); + lndInvoice = `lightning:${lnpart}`; + if (!this.isLightningInvoice(lndInvoice)) { + lndInvoice = false; + break; + } + } + } catch (e) { + console.log(e); + } + if (btc && lndInvoice) break; + } + if (btc && lndInvoice) { + return { bitcoin: btc, lndInvoice }; + } else { + return undefined; + } + } + return undefined; + } + + static bip21decode(uri?: string) { + if (!uri) { + throw new Error('No URI provided'); + } + let replacedUri = uri; + for (const replaceMe of ['BITCOIN://', 'bitcoin://', 'BITCOIN:']) { + replacedUri = replacedUri.replace(replaceMe, 'bitcoin:'); + } + + return bip21.decode(replacedUri); + } + + static bip21encode(address: string, options?: TOptions): string { + // uppercase address if bech32 to satisfy BIP_0173 + const isBech32 = address.startsWith('bc1'); + if (isBech32) { + address = address.toUpperCase(); + } + + for (const key in options) { + if (key === 'label' && String(options[key]).replace(' ', '').length === 0) { + delete options[key]; + } + if (key === 'amount' && !(Number(options[key]) > 0)) { + delete options[key]; + } + } + return bip21.encode(address, options); + } + + static decodeBitcoinUri(uri: string) { + let amount; + let address = uri || ''; + let memo = ''; + let payjoinUrl = ''; + try { + const parsedBitcoinUri = DeeplinkSchemaMatch.bip21decode(uri); + address = parsedBitcoinUri.address ? parsedBitcoinUri.address.toString() : address; + if ('options' in parsedBitcoinUri) { + if (parsedBitcoinUri.options.amount) { + amount = Number(parsedBitcoinUri.options.amount); + } + if (parsedBitcoinUri.options.label) { + memo = parsedBitcoinUri.options.label; + } + if (parsedBitcoinUri.options.pj) { + payjoinUrl = parsedBitcoinUri.options.pj; + } + } + } catch (_) {} + return { address, amount, memo, payjoinUrl }; + } +} + +export default DeeplinkSchemaMatch; diff --git a/class/hd-segwit-bech32-transaction.js b/class/hd-segwit-bech32-transaction.js deleted file mode 100644 index 758a120a9ab..00000000000 --- a/class/hd-segwit-bech32-transaction.js +++ /dev/null @@ -1,371 +0,0 @@ -import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; -import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; -const bitcoin = require('bitcoinjs-lib'); -const BlueElectrum = require('../blue_modules/BlueElectrum'); -const reverse = require('buffer-reverse'); -const BigNumber = require('bignumber.js'); - -/** - * Represents transaction of a BIP84 wallet. - * Helpers for RBF, CPFP etc. - */ -export class HDSegwitBech32Transaction { - /** - * @param txhex {string|null} Object is initialized with txhex - * @param txid {string|null} If txhex not present - txid whould be present - * @param wallet {HDSegwitBech32Wallet|null} If set - a wallet object to which transacton belongs - */ - constructor(txhex, txid, wallet) { - if (!txhex && !txid) throw new Error('Bad arguments'); - this._txhex = txhex; - this._txid = txid; - - if (wallet) { - if (wallet.type === HDSegwitBech32Wallet.type) { - /** @type {HDSegwitBech32Wallet} */ - this._wallet = wallet; - } else { - throw new Error('Only HD Bech32 wallets supported'); - } - } - - if (this._txhex) this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); - this._remoteTx = null; - } - - /** - * If only txid present - we fetch hex - * - * @returns {Promise} - * @private - */ - async _fetchTxhexAndDecode() { - const hexes = await BlueElectrum.multiGetTransactionByTxid([this._txid], 10, false); - this._txhex = hexes[this._txid]; - if (!this._txhex) throw new Error("Transaction can't be found in mempool"); - this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); - } - - /** - * Returns max used sequence for this transaction. Next RBF transaction - * should have this sequence + 1 - * - * @returns {Promise} - */ - async getMaxUsedSequence() { - if (!this._txDecoded) await this._fetchTxhexAndDecode(); - - let max = 0; - for (const inp of this._txDecoded.ins) { - max = Math.max(inp.sequence, max); - } - - return max; - } - - /** - * Basic check that Sequence num for this TX is replaceable - * - * @returns {Promise} - */ - async isSequenceReplaceable() { - return (await this.getMaxUsedSequence()) < bitcoin.Transaction.DEFAULT_SEQUENCE; - } - - /** - * If internal extended tx data not set - this is a method - * to fetch and set this data from electrum. Its different data from - * decoded hex - it contains confirmations etc. - * - * @returns {Promise} - * @private - */ - async _fetchRemoteTx() { - const result = await BlueElectrum.multiGetTransactionByTxid([this._txid || this._txDecoded.getId()]); - this._remoteTx = Object.values(result)[0]; - } - - /** - * Fetches from electrum actual confirmations number for this tx - * - * @returns {Promise} - */ - async getRemoteConfirmationsNum() { - if (!this._remoteTx) await this._fetchRemoteTx(); - return this._remoteTx.confirmations || 0; // stupid undefined - } - - /** - * Checks that tx belongs to a wallet and also - * tx value is < 0, which means its a spending transaction - * definately initiated by us, can be RBF'ed. - * - * @returns {Promise} - */ - async isOurTransaction() { - if (!this._wallet) throw new Error('Wallet required for this method'); - let found = false; - for (const tx of this._wallet.getTransactions()) { - if (tx.txid === (this._txid || this._txDecoded.getId())) { - // its our transaction, and its spending transaction, which means we initiated it - if (tx.value < 0) found = true; - } - } - return found; - } - - /** - * Checks that tx belongs to a wallet and also - * tx value is > 0, which means its a receiving transaction and thus - * can be CPFP'ed. - * - * @returns {Promise} - */ - async isToUsTransaction() { - if (!this._wallet) throw new Error('Wallet required for this method'); - let found = false; - for (const tx of this._wallet.getTransactions()) { - if (tx.txid === (this._txid || this._txDecoded.getId())) { - if (tx.value > 0) found = true; - } - } - return found; - } - - /** - * Returns all the info about current transaction which is needed to do a replacement TX - * * fee - current tx fee - * * utxos - UTXOs current tx consumes - * * changeAmount - amount of satoshis that sent to change address (or addresses) we control - * * feeRate - sat/byte for current tx - * * targets - destination(s) of funds (outputs we do not control) - * * unconfirmedUtxos - UTXOs created by this transaction (only the ones we control) - * - * @returns {Promise<{fee: number, utxos: Array, unconfirmedUtxos: Array, changeAmount: number, feeRate: number, targets: Array}>} - */ - async getInfo() { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._remoteTx) await this._fetchRemoteTx(); - if (!this._txDecoded) await this._fetchTxhexAndDecode(); - - const prevInputs = []; - for (const inp of this._txDecoded.ins) { - let reversedHash = Buffer.from(reverse(inp.hash)); - reversedHash = reversedHash.toString('hex'); - prevInputs.push(reversedHash); - } - - const prevTransactions = await BlueElectrum.multiGetTransactionByTxid(prevInputs); - - // fetched, now lets count how much satoshis went in - let wentIn = 0; - const utxos = []; - for (const inp of this._txDecoded.ins) { - let reversedHash = Buffer.from(reverse(inp.hash)); - reversedHash = reversedHash.toString('hex'); - if (prevTransactions[reversedHash] && prevTransactions[reversedHash].vout && prevTransactions[reversedHash].vout[inp.index]) { - let value = prevTransactions[reversedHash].vout[inp.index].value; - value = new BigNumber(value).multipliedBy(100000000).toNumber(); - wentIn += value; - const address = SegwitBech32Wallet.witnessToAddress(inp.witness[inp.witness.length - 1]); - utxos.push({ vout: inp.index, value, txId: reversedHash, address }); - } - } - - // counting how much went into actual outputs - - let wasSpent = 0; - for (const outp of this._txDecoded.outs) { - wasSpent += +outp.value; - } - - const fee = wentIn - wasSpent; - let feeRate = Math.floor(fee / this._txDecoded.virtualSize()); - if (feeRate === 0) feeRate = 1; - - // lets take a look at change - let changeAmount = 0; - const targets = []; - for (const outp of this._remoteTx.vout) { - const address = outp.scriptPubKey.addresses[0]; - const value = new BigNumber(outp.value).multipliedBy(100000000).toNumber(); - if (this._wallet.weOwnAddress(address)) { - changeAmount += value; - } else { - // this is target - targets.push({ value, address }); - } - } - - // lets find outputs we own that current transaction creates. can be used in CPFP - const unconfirmedUtxos = []; - for (const outp of this._remoteTx.vout) { - const address = outp.scriptPubKey.addresses[0]; - const value = new BigNumber(outp.value).multipliedBy(100000000).toNumber(); - if (this._wallet.weOwnAddress(address)) { - unconfirmedUtxos.push({ - vout: outp.n, - value, - txId: this._txid || this._txDecoded.getId(), - address, - }); - } - } - - return { fee, feeRate, targets, changeAmount, utxos, unconfirmedUtxos }; - } - - /** - * We get _all_ our UTXOs (even spent kek), - * and see if each input in this transaction's UTXO is in there. If its not there - its an unknown - * input, we dont own it (possibly a payjoin transaction), and we cant do RBF - * - * @returns {Promise} - */ - async thereAreUnknownInputsInTx() { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._txDecoded) await this._fetchTxhexAndDecode(); - - const spentUtxos = this._wallet.getDerivedUtxoFromOurTransaction(true); - for (const inp of this._txDecoded.ins) { - const txidInUtxo = reverse(inp.hash).toString('hex'); - - let found = false; - for (const spentU of spentUtxos) { - if (spentU.txid === txidInUtxo && spentU.vout === inp.index) found = true; - } - - if (!found) { - return true; - } - } - } - - /** - * Checks if all outputs belong to us, that - * means we already canceled this tx and we can only bump fees - * - * @returns {Promise} - */ - async canCancelTx() { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._txDecoded) await this._fetchTxhexAndDecode(); - - if (await this.thereAreUnknownInputsInTx()) return false; - - // if theres at least one output we dont own - we can cancel this transaction! - for (const outp of this._txDecoded.outs) { - if (!this._wallet.weOwnAddress(SegwitBech32Wallet.scriptPubKeyToAddress(outp.script))) return true; - } - - return false; - } - - async canBumpTx() { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._txDecoded) await this._fetchTxhexAndDecode(); - - if (await this.thereAreUnknownInputsInTx()) return false; - - return true; - } - - /** - * Creates an RBF transaction that can replace previous one and basically cancel it (rewrite - * output to the one our wallet controls). Note, this cannot add more utxo in RBF transaction if - * newFeerate is too high - * - * @param newFeerate {number} Sat/byte. Should be greater than previous tx feerate - * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} - */ - async createRBFcancelTx(newFeerate) { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._remoteTx) await this._fetchRemoteTx(); - - const { feeRate, utxos } = await this.getInfo(); - - if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); - const myAddress = await this._wallet.getChangeAddressAsync(); - - return this._wallet.createTransaction( - utxos, - [{ address: myAddress }], - newFeerate, - /* meaningless in this context */ myAddress, - (await this.getMaxUsedSequence()) + 1, - ); - } - - /** - * Creates an RBF transaction that can bumps fee of previous one. Note, this cannot add more utxo in RBF - * transaction if newFeerate is too high - * - * @param newFeerate {number} Sat/byte - * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} - */ - async createRBFbumpFee(newFeerate) { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._remoteTx) await this._fetchRemoteTx(); - - const { feeRate, targets, changeAmount, utxos } = await this.getInfo(); - - if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); - const myAddress = await this._wallet.getChangeAddressAsync(); - - if (changeAmount === 0) delete targets[0].value; - // looks like this was sendMAX transaction (because there was no change), so we cant reuse amount in this - // target since fee wont change. removing the amount so `createTransaction` will sendMAX correctly with new feeRate - - if (targets.length === 0) { - // looks like this was cancelled tx with single change output, so it wasnt included in `this.getInfo()` targets - // so we add output paying ourselves: - targets.push({ address: this._wallet._getInternalAddressByIndex(this._wallet.next_free_change_address_index) }); - // not checking emptiness on purpose: it could unpredictably generate too far address because of unconfirmed tx. - } - - return this._wallet.createTransaction(utxos, targets, newFeerate, myAddress, (await this.getMaxUsedSequence()) + 1); - } - - /** - * Creates a CPFP transaction that can bumps fee of previous one (spends created but not confirmed outputs - * that belong to us). Note, this cannot add more utxo in CPFP transaction if newFeerate is too high - * - * @param newFeerate {number} sat/byte - * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} - */ - async createCPFPbumpFee(newFeerate) { - if (!this._wallet) throw new Error('Wallet required for this method'); - if (!this._remoteTx) await this._fetchRemoteTx(); - - const { feeRate, fee: oldFee, unconfirmedUtxos } = await this.getInfo(); - - if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); - const myAddress = await this._wallet.getChangeAddressAsync(); - - // calculating feerate for CPFP tx so that average between current and CPFP tx will equal newFeerate. - // this works well if both txs are +/- equal size in bytes - const targetFeeRate = 2 * newFeerate - feeRate; - - let add = 0; - while (add <= 128) { - // eslint-disable-next-line no-var - var { tx, inputs, outputs, fee } = this._wallet.createTransaction( - unconfirmedUtxos, - [{ address: myAddress }], - targetFeeRate + add, - myAddress, - HDSegwitBech32Wallet.defaultRBFSequence, - ); - const combinedFeeRate = (oldFee + fee) / (this._txDecoded.virtualSize() + tx.virtualSize()); // avg - if (Math.round(combinedFeeRate) < newFeerate) { - add *= 2; - if (!add) add = 2; - } else { - // reached target feerate - break; - } - } - - return { tx, inputs, outputs, fee }; - } -} diff --git a/class/hd-segwit-bech32-transaction.ts b/class/hd-segwit-bech32-transaction.ts new file mode 100644 index 00000000000..c6b159f091c --- /dev/null +++ b/class/hd-segwit-bech32-transaction.ts @@ -0,0 +1,427 @@ +import BigNumber from 'bignumber.js'; +import * as bitcoin from 'bitcoinjs-lib'; +import assert from 'assert'; + +import * as BlueElectrum from '../blue_modules/BlueElectrum'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { CreateTransactionUtxo } from './wallets/types.ts'; +import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; +import { isUint8Array, uint8ArrayToHex } from '../blue_modules/uint8array-extras'; + +/** + * Represents transaction of a BIP84 wallet. + * Helpers for RBF, CPFP etc. + */ +export class HDSegwitBech32Transaction { + private _txhex: string | null; + private _txid: string | null; + private _wallet: HDSegwitBech32Wallet | undefined; + private _txDecoded: bitcoin.Transaction | undefined; + private _remoteTx: any; + private _mfp?: number; + + /** + * @param txhex {string|null} Object is initialized with txhex + * @param txid {string|null} If txhex not present - txid whould be present + * @param wallet {HDSegwitBech32Wallet|null} If set - a wallet object to which transacton belongs + * @param mfp {number|undefined} set mfp if it is an HD Segwit Bech32 watch-only wallet + */ + constructor(txhex: string | null, txid: string | null, wallet: HDSegwitBech32Wallet | null, mfp?: number) { + if (!txhex && !txid) throw new Error('Bad arguments'); + this._txhex = txhex; + this._txid = txid; + + if (mfp !== undefined) { + this._mfp = mfp; + } + + if (wallet) { + if (wallet.type === HDSegwitBech32Wallet.type) { + this._wallet = wallet; + } else { + throw new Error('Only HD Bech32 wallets supported'); + } + } + + if (this._txhex) this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); + this._remoteTx = null; + } + + /** + * If only txid present - we fetch hex + * + * @returns {Promise} + * @private + */ + async _fetchTxhexAndDecode() { + assert(this._txid, 'this._txid must be a string'); + const hexes = await BlueElectrum.multiGetTransactionByTxid([this._txid], false, 10); + this._txhex = hexes[this._txid]; + if (!this._txhex) throw new Error("Transaction can't be found in mempool"); + this._txDecoded = bitcoin.Transaction.fromHex(this._txhex); + } + + /** + * Returns max used sequence for this transaction. Next RBF transaction + * should have this sequence + 1 + * + * @returns {Promise} + */ + async getMaxUsedSequence() { + if (!this._txDecoded) await this._fetchTxhexAndDecode(); + assert(this._txDecoded, 'Could not fetch tx and decode'); + + let max = 0; + for (const inp of this._txDecoded.ins) { + max = Math.max(inp.sequence, max); + } + + return max; + } + + /** + * Basic check that Sequence num for this TX is replaceable + * + * @returns {Promise} + */ + async isSequenceReplaceable() { + return (await this.getMaxUsedSequence()) < bitcoin.Transaction.DEFAULT_SEQUENCE; + } + + /** + * If internal extended tx data not set - this is a method + * to fetch and set this data from electrum. Its different data from + * decoded hex - it contains confirmations etc. + * + * @returns {Promise} + * @private + */ + async _fetchRemoteTx() { + const result = await BlueElectrum.multiGetTransactionByTxid([this._txid || this._txDecoded!.getId()], true); + this._remoteTx = Object.values(result)[0]; + } + + /** + * Fetches from electrum actual confirmations number for this tx + * + * @returns {Promise} + */ + async getRemoteConfirmationsNum() { + if (!this._remoteTx) await this._fetchRemoteTx(); + return this._remoteTx.confirmations || 0; // stupid undefined + } + + /** + * Checks that tx belongs to a wallet and also + * tx value is < 0, which means its a spending transaction + * definitely initiated by us, can be RBF'ed. + * + * @returns {Promise} + */ + async isOurTransaction() { + if (!this._wallet) throw new Error('Wallet required for this method'); + let found = false; + for (const tx of this._wallet.getTransactions()) { + if (tx.txid === (this._txid || this._txDecoded!.getId())) { + // its our transaction, and its spending transaction, which means we initiated it + if (tx.value && tx.value < 0) found = true; + } + } + return found; + } + + /** + * Checks that tx belongs to a wallet and also + * tx value is > 0, which means its a receiving transaction and thus + * can be CPFP'ed. + * + * @returns {Promise} + */ + async isToUsTransaction() { + if (!this._wallet) throw new Error('Wallet required for this method'); + let found = false; + for (const tx of this._wallet.getTransactions()) { + if (tx.txid === (this._txid || this._txDecoded!.getId())) { + if (tx.value && tx.value > 0) found = true; + } + } + return found; + } + + /** + * Returns all the info about current transaction which is needed to do a replacement TX + * * fee - current tx fee + * * utxos - UTXOs current tx consumes + * * changeAmount - amount of satoshis that sent to change address (or addresses) we control + * * feeRate - sat/byte for current tx + * * targets - destination(s) of funds (outputs we do not control) + * * unconfirmedUtxos - UTXOs created by this transaction (only the ones we control) + * + * @returns {Promise<{fee: number, utxos: Array, unconfirmedUtxos: Array, changeAmount: number, feeRate: number, targets: Array}>} + */ + async getInfo() { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + if (!this._txDecoded) await this._fetchTxhexAndDecode(); + assert(this._txDecoded, 'could not fetch tx and decode'); + + const prevInputs = []; + for (const inp of this._txDecoded.ins) { + prevInputs.push(uint8ArrayToHex(new Uint8Array(inp.hash).reverse())); + } + + const prevTransactions = await BlueElectrum.multiGetTransactionByTxid(prevInputs, true); + + // fetched, now lets count how much satoshis went in + let wentIn = 0; + const utxos: CreateTransactionUtxo[] = []; + for (const inp of this._txDecoded.ins) { + const reversedHash = uint8ArrayToHex(new Uint8Array(inp.hash).reverse()); + if (prevTransactions[reversedHash] && prevTransactions[reversedHash].vout && prevTransactions[reversedHash].vout[inp.index]) { + let value = prevTransactions[reversedHash].vout[inp.index].value; + value = new BigNumber(value).multipliedBy(100000000).toNumber(); + wentIn += value; + const witness = inp.witness[inp.witness.length - 1]; + const address = String(SegwitBech32Wallet.witnessToAddress(isUint8Array(witness) ? uint8ArrayToHex(witness) : witness)); + utxos.push({ vout: inp.index, value, txid: reversedHash, address }); + } + } + + // counting how much went into actual outputs + + let wasSpent = 0; + for (const outp of this._txDecoded.outs) { + wasSpent += Number(outp.value); + } + + const fee = wentIn - wasSpent; + let feeRate = Math.floor(fee / this._txDecoded.virtualSize()); + if (feeRate === 0) feeRate = 1; + + // lets take a look at change + let changeAmount = 0; + const targets: { value?: number; address: string }[] = []; + for (const outp of this._remoteTx.vout) { + const address = outp.scriptPubKey.addresses[0]; + const value = new BigNumber(outp.value).multipliedBy(100000000).toNumber(); + if (this._wallet.weOwnAddress(address)) { + changeAmount += value; + } else { + // this is target + targets.push({ value, address }); + } + } + + // lets find outputs we own that current transaction creates. can be used in CPFP + const unconfirmedUtxos = []; + for (const outp of this._remoteTx.vout) { + const address = outp.scriptPubKey.addresses[0]; + const value = new BigNumber(outp.value).multipliedBy(100000000).toNumber(); + if (this._wallet.weOwnAddress(address)) { + unconfirmedUtxos.push({ + vout: outp.n, + value, + txid: this._txid || this._txDecoded.getId(), + address, + }); + } + } + + return { fee, feeRate, targets, changeAmount, utxos, unconfirmedUtxos }; + } + + /** + * We get _all_ our UTXOs (even spent kek), + * and see if each input in this transaction's UTXO is in there. If its not there - its an unknown + * input, we dont own it (possibly a payjoin transaction), and we cant do RBF + * + * @returns {Promise} + */ + async thereAreUnknownInputsInTx() { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._txDecoded) await this._fetchTxhexAndDecode(); + assert(this._txDecoded, 'could not fetch tx and decode'); + + const spentUtxos = this._wallet.getDerivedUtxoFromOurTransaction(true); + for (const inp of this._txDecoded.ins) { + const txidInUtxo = uint8ArrayToHex(new Uint8Array(inp.hash).reverse()); + + let found = false; + for (const spentU of spentUtxos) { + if (spentU.txid === txidInUtxo && spentU.vout === inp.index) found = true; + } + + if (!found) { + return true; + } + } + } + + /** + * Checks if all outputs belong to us, that + * means we already canceled this tx and we can only bump fees + * + * @returns {Promise} + */ + async canCancelTx() { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._txDecoded) await this._fetchTxhexAndDecode(); + assert(this._txDecoded, 'could not fetch tx and decode'); + + if (await this.thereAreUnknownInputsInTx()) return false; + + // if theres at least one output we dont own - we can cancel this transaction! + for (const outp of this._txDecoded.outs) { + const outpScript = outp.script; + if ( + !this._wallet.weOwnAddress( + String(SegwitBech32Wallet.scriptPubKeyToAddress(isUint8Array(outpScript) ? uint8ArrayToHex(outpScript) : outpScript)), + ) + ) + return true; + } + + return false; + } + + async canBumpTx() { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._txDecoded) await this._fetchTxhexAndDecode(); + + if (await this.thereAreUnknownInputsInTx()) return false; + + return true; + } + + /** + * Creates an RBF transaction that can replace previous one and basically cancel it (rewrite + * output to the one our wallet controls). Note, this cannot add more utxo in RBF transaction if + * newFeerate is too high + * + * @param newFeerate {number} Sat/byte. Should be greater than previous tx feerate + * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} + */ + async createRBFcancelTx(newFeerate: any) { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + const { feeRate, utxos } = await this.getInfo(); + + if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); + const myAddress = await this._wallet.getChangeAddressAsync(); + + // if there is no secret then its a watch only wallet, skip signing and also pass the masterfingerprint + if (!this._wallet.secret) { + return this._wallet.createTransaction( + utxos, + [{ address: myAddress }], + newFeerate, + myAddress, + (await this.getMaxUsedSequence()) + 1, + true, + this._mfp ?? 0, + ); + } + + return this._wallet.createTransaction( + utxos, + [{ address: myAddress }], + newFeerate, + /* meaningless in this context */ myAddress, + (await this.getMaxUsedSequence()) + 1, + ); + } + + /** + * Creates an RBF transaction that can bumps fee of previous one. Note, this cannot add more utxo in RBF + * transaction if newFeerate is too high + * + * @param newFeerate {number} Sat/byte + * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} + */ + async createRBFbumpFee(newFeerate: number) { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + const { feeRate, targets, changeAmount, utxos } = await this.getInfo(); + + if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); + const myAddress = await this._wallet.getChangeAddressAsync(); + + if (changeAmount === 0) delete targets[0].value; + // looks like this was sendMAX transaction (because there was no change), so we cant reuse amount in this + // target since fee wont change. removing the amount so `createTransaction` will sendMAX correctly with new feeRate + + if (targets.length === 0) { + // looks like this was cancelled tx with single change output, so it wasnt included in `this.getInfo()` targets + // so we add output paying ourselves: + targets.push({ address: this._wallet._getInternalAddressByIndex(this._wallet.next_free_change_address_index) }); + // not checking emptiness on purpose: it could unpredictably generate too far address because of unconfirmed tx. + } + + // if there is no secret then its a watch only wallet, skip signing and also pass the masterfingerprint + if (!this._wallet.secret) { + return this._wallet.createTransaction( + utxos, + targets, + newFeerate, + myAddress, + (await this.getMaxUsedSequence()) + 1, + true, + this._mfp ?? 0, + ); + } + + return this._wallet.createTransaction(utxos, targets, newFeerate, myAddress, (await this.getMaxUsedSequence()) + 1); + } + + /** + * Creates a CPFP transaction that can bumps fee of previous one (spends created but not confirmed outputs + * that belong to us). Note, this cannot add more utxo in CPFP transaction if newFeerate is too high + * + * @param newFeerate {number} sat/byte + * @returns {Promise<{outputs: Array, tx: Transaction, inputs: Array, fee: Number}>} + */ + async createCPFPbumpFee(newFeerate: number) { + if (!this._wallet) throw new Error('Wallet required for this method'); + if (!this._remoteTx) await this._fetchRemoteTx(); + + const { feeRate, fee: oldFee, unconfirmedUtxos } = await this.getInfo(); + + if (newFeerate <= feeRate) throw new Error('New feerate should be bigger than the old one'); + const myAddress = await this._wallet.getChangeAddressAsync(); + + // calculating feerate for CPFP tx so that average between current and CPFP tx will equal newFeerate. + // this works well if both txs are +/- equal size in bytes + const targetFeeRate = 2 * newFeerate - feeRate; + + let add = 0; + let tx: bitcoin.Transaction | undefined, inputs: CoinSelectReturnInput[], outputs: CoinSelectOutput[], fee: number; + while (add <= 128) { + const createdTx = this._wallet.createTransaction( + unconfirmedUtxos, + [{ address: myAddress }], + targetFeeRate + add, + myAddress, + HDSegwitBech32Wallet.defaultRBFSequence, + ); + tx = createdTx.tx; + inputs = createdTx.inputs; + outputs = createdTx.outputs; + fee = createdTx.fee; + assert(tx, 'tx is createCPFPbumpFee() is undefined'); + const combinedFeeRate = (oldFee + fee) / (this._txDecoded!.virtualSize() + tx.virtualSize()); // avg + if (combinedFeeRate < newFeerate) { + add *= 2; + if (!add) add = 2; + } else { + // reached target feerate + break; + } + } + + // Non-null assertions are safe here because the while loop always runs at least once (add starts at 0) + return { tx: tx!, inputs: inputs!, outputs: outputs!, fee: fee! }; + } +} diff --git a/class/index.js b/class/index.js deleted file mode 100644 index 9436a9faec9..00000000000 --- a/class/index.js +++ /dev/null @@ -1,20 +0,0 @@ -export * from './wallets/abstract-wallet'; -export * from './wallets/legacy-wallet'; -export * from './wallets/segwit-bech32-wallet'; -export * from './wallets/taproot-wallet'; -export * from './wallets/segwit-p2sh-wallet'; -export * from './wallets/hd-segwit-p2sh-wallet'; -export * from './wallets/hd-legacy-breadwallet-wallet'; -export * from './wallets/hd-legacy-p2pkh-wallet'; -export * from './wallets/watch-only-wallet'; -export * from './wallets/lightning-custodian-wallet'; -export * from './wallets/lightning-ldk-wallet'; -export * from './wallets/abstract-hd-wallet'; -export * from './wallets/hd-segwit-bech32-wallet'; -export * from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; -export * from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; -export * from './wallets/hd-aezeed-wallet'; -export * from './wallets/multisig-hd-wallet'; -export * from './wallets/slip39-wallets'; -export * from './hd-segwit-bech32-transaction'; -export * from './multisig-cosigner'; diff --git a/class/lnurl.js b/class/lnurl.js deleted file mode 100644 index 6e23959e44f..00000000000 --- a/class/lnurl.js +++ /dev/null @@ -1,366 +0,0 @@ -import { bech32 } from 'bech32'; -import bolt11 from 'bolt11'; -import { isTorDaemonDisabled } from '../blue_modules/environment'; -import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api -import { createHmac } from 'crypto'; -import secp256k1 from 'secp256k1'; -const CryptoJS = require('crypto-js'); -const createHash = require('create-hash'); -const torrific = require('../blue_modules/torrific'); -const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex for onion URL - -/** - * @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md - */ -export default class Lnurl { - static TAG_PAY_REQUEST = 'payRequest'; // type of LNURL - static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL - static TAG_LOGIN_REQUEST = 'login'; // type of LNURL - - constructor(url, AsyncStorage) { - this._lnurl = url; - this._lnurlPayServiceBolt11Payload = false; - this._lnurlPayServicePayload = false; - this._AsyncStorage = AsyncStorage; - this._preimage = false; - } - - static findlnurl(bodyOfText) { - const res = /^(?:http.*[&?]lightning=|lightning:)?(lnurl1[02-9ac-hj-np-z]+)/.exec(bodyOfText.toLowerCase()); - if (res) { - return res[1]; - } - return null; - } - - static getUrlFromLnurl(lnurlExample) { - const found = Lnurl.findlnurl(lnurlExample); - if (!found) { - if (Lnurl.isLightningAddress(lnurlExample)) { - const username = lnurlExample.split('@')[0].trim(); - const host = lnurlExample.split('@')[1].trim(); - const proto = host.match(/\.onion$/) ? 'http' : 'https'; - return `${proto}://${host}/.well-known/lnurlp/${username}`; - } else { - return false; - } - } - - const decoded = bech32.decode(found, 10000); - return Buffer.from(bech32.fromWords(decoded.words)).toString(); - } - - static isLnurl(url) { - return Lnurl.findlnurl(url) !== null; - } - - static isOnionUrl(url) { - return Lnurl.parseOnionUrl(url) !== null; - } - - static parseOnionUrl(url) { - const match = url.match(ONION_REGEX); - if (match === null) return null; - const [, baseURI, path] = match; - return [baseURI, path]; - } - - async fetchGet(url) { - const parsedOnionUrl = Lnurl.parseOnionUrl(url); - if (parsedOnionUrl) { - return _fetchGetTor(parsedOnionUrl); - } - - const resp = await fetch(url, { method: 'GET' }); - if (resp.status >= 300) { - throw new Error('Bad response from server'); - } - const reply = await resp.json(); - if (reply.status === 'ERROR') { - throw new Error('Reply from server: ' + reply.reason); - } - return reply; - } - - decodeInvoice(invoice) { - const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); - - const decoded = { - destination: payeeNodeKey, - num_satoshis: satoshis ? satoshis.toString() : '0', - num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0', - timestamp: timestamp.toString(), - fallback_addr: '', - route_hints: [], - }; - - for (let i = 0; i < tags.length; i++) { - const { tagName, data } = tags[i]; - switch (tagName) { - case 'payment_hash': - decoded.payment_hash = data; - break; - case 'purpose_commit_hash': - decoded.description_hash = data; - break; - case 'min_final_cltv_expiry': - decoded.cltv_expiry = data.toString(); - break; - case 'expire_time': - decoded.expiry = data.toString(); - break; - case 'description': - decoded.description = data; - break; - } - } - - if (!decoded.expiry) decoded.expiry = '3600'; // default - - if (parseInt(decoded.num_satoshis, 10) === 0 && decoded.num_millisatoshis > 0) { - decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString(); - } - - return decoded; - } - - async requestBolt11FromLnurlPayService(amountSat, comment = '') { - if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set'); - if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set'); - if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max) - throw new Error( - 'The specified amount is invalid, ' + - amountSat + - ' it should be between ' + - this._lnurlPayServicePayload.min + - ' and ' + - this._lnurlPayServicePayload.max, - ); - const nonce = Math.floor(Math.random() * 2e16).toString(16); - const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&'; - if (this.getCommentAllowed() && comment && comment.length > this.getCommentAllowed()) { - comment = comment.substr(0, this.getCommentAllowed()); - } - if (comment) comment = `&comment=${encodeURIComponent(comment)}`; - const urlToFetch = - this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce + comment; - this._lnurlPayServiceBolt11Payload = await this.fetchGet(urlToFetch); - if (this._lnurlPayServiceBolt11Payload.status === 'ERROR') - throw new Error(this._lnurlPayServiceBolt11Payload.reason || 'requestBolt11FromLnurlPayService() error'); - - // check pr description_hash, amount etc: - const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr); - const metadataHash = createHash('sha256').update(this._lnurlPayServicePayload.metadata).digest('hex'); - if (metadataHash !== decoded.description_hash) { - throw new Error(`Invoice description_hash doesn't match metadata.`); - } - if (parseInt(decoded.num_satoshis, 10) !== Math.round(amountSat)) { - throw new Error(`Invoice doesn't match specified amount, got ${decoded.num_satoshis}, expected ${Math.round(amountSat)}`); - } - - return this._lnurlPayServiceBolt11Payload; - } - - async callLnurlPayService() { - if (!this._lnurl) throw new Error('this._lnurl is not set'); - const url = Lnurl.getUrlFromLnurl(this._lnurl); - // calling the url - const reply = await this.fetchGet(url); - - if (reply.tag !== Lnurl.TAG_PAY_REQUEST) { - throw new Error('lnurl-pay expected, found tag ' + reply.tag); - } - - const data = reply; - - // parse metadata and extract things from it - let image; - let description; - const kvs = JSON.parse(data.metadata); - for (let i = 0; i < kvs.length; i++) { - const [k, v] = kvs[i]; - switch (k) { - case 'text/plain': - description = v; - break; - case 'image/png;base64': - case 'image/jpeg;base64': - image = 'data:' + k + ',' + v; - break; - } - } - - // setting the payment screen with the parameters - const min = Math.ceil((data.minSendable || 0) / 1000); - const max = Math.floor(data.maxSendable / 1000); - - this._lnurlPayServicePayload = { - callback: data.callback, - fixed: min === max, - min, - max, - domain: data.callback.match(/^(https|http):\/\/([^/]+)\//)[2], - metadata: data.metadata, - description, - image, - amount: min, - commentAllowed: data.commentAllowed, - // lnurl: uri, - }; - return this._lnurlPayServicePayload; - } - - async loadSuccessfulPayment(paymentHash) { - if (!paymentHash) throw new Error('No paymentHash provided'); - let data; - try { - data = await this._AsyncStorage.getItem('lnurlpay_success_data_' + paymentHash); - data = JSON.parse(data); - } catch (_) { - return false; - } - - if (!data) return false; - - this._lnurlPayServicePayload = data.lnurlPayServicePayload; - this._lnurlPayServiceBolt11Payload = data.lnurlPayServiceBolt11Payload; - this._lnurl = data.lnurl; - this._preimage = data.preimage; - - return true; - } - - async storeSuccess(paymentHash, preimage) { - if (typeof preimage === 'object') { - preimage = Buffer.from(preimage.data).toString('hex'); - } - this._preimage = preimage; - - await this._AsyncStorage.setItem( - 'lnurlpay_success_data_' + paymentHash, - JSON.stringify({ - lnurlPayServicePayload: this._lnurlPayServicePayload, - lnurlPayServiceBolt11Payload: this._lnurlPayServiceBolt11Payload, - lnurl: this._lnurl, - preimage, - }), - ); - } - - getSuccessAction() { - return this._lnurlPayServiceBolt11Payload.successAction; - } - - getDomain() { - return this._lnurlPayServicePayload.domain; - } - - getDescription() { - return this._lnurlPayServicePayload.description; - } - - getImage() { - return this._lnurlPayServicePayload.image; - } - - getLnurl() { - return this._lnurl; - } - - getDisposable() { - return this._lnurlPayServiceBolt11Payload.disposable; - } - - getPreimage() { - return this._preimage; - } - - static decipherAES(ciphertextBase64, preimageHex, ivBase64) { - const iv = CryptoJS.enc.Base64.parse(ivBase64); - const key = CryptoJS.enc.Hex.parse(preimageHex); - return CryptoJS.AES.decrypt(Buffer.from(ciphertextBase64, 'base64').toString('hex'), key, { - iv, - mode: CryptoJS.mode.CBC, - format: CryptoJS.format.Hex, - }).toString(CryptoJS.enc.Utf8); - } - - getCommentAllowed() { - return this?._lnurlPayServicePayload?.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed, 10) : false; - } - - getMin() { - return this?._lnurlPayServicePayload?.min ? parseInt(this._lnurlPayServicePayload.min, 10) : false; - } - - getMax() { - return this?._lnurlPayServicePayload?.max ? parseInt(this._lnurlPayServicePayload.max, 10) : false; - } - - getAmount() { - return this.getMin(); - } - - authenticate(secret) { - return new Promise((resolve, reject) => { - if (!this._lnurl) throw new Error('this._lnurl is not set'); - - const url = parse(Lnurl.getUrlFromLnurl(this._lnurl), true); - - const hmac = createHmac('sha256', secret); - hmac.on('readable', async () => { - try { - const privateKey = hmac.read(); - if (!privateKey) return; - const privateKeyBuf = Buffer.from(privateKey, 'hex'); - const publicKey = secp256k1.publicKeyCreate(privateKeyBuf); - const signatureObj = secp256k1.sign(Buffer.from(url.query.k1, 'hex'), privateKeyBuf); - const derSignature = secp256k1.signatureExport(signatureObj.signature); - - const reply = await this.fetchGet(`${url.href}&sig=${derSignature.toString('hex')}&key=${publicKey.toString('hex')}`); - if (reply.status === 'OK') { - resolve(); - } else { - reject(reply.reason); - } - } catch (err) { - reject(err); - } - }); - hmac.write(url.hostname); - hmac.end(); - }); - } - - static isLightningAddress(address) { - // ensure only 1 `@` present: - if (address.split('@').length !== 2) return false; - const splitted = address.split('@'); - return !!splitted[0].trim() && !!splitted[1].trim(); - } -} - -async function _fetchGetTor(parsedOnionUrl) { - const torDaemonDisabled = await isTorDaemonDisabled(); - if (torDaemonDisabled) { - throw new Error('Tor onion url support disabled'); - } - const [baseURI, path] = parsedOnionUrl; - const tor = new torrific.Torsbee({ - baseURI, - }); - const response = await tor.get(path || '/', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - }, - }); - const json = response.body; - if (typeof json === 'undefined' || response.err) { - throw new Error('Bad response from server: ' + response.err + ' ' + JSON.stringify(response.body)); - } - if (json.status === 'ERROR') { - throw new Error('Reply from server: ' + json.reason); - } - return json; -} diff --git a/class/lnurl.ts b/class/lnurl.ts new file mode 100644 index 00000000000..80f333dca98 --- /dev/null +++ b/class/lnurl.ts @@ -0,0 +1,393 @@ +import { bech32 } from 'bech32'; +import bolt11 from 'bolt11'; +import { sha256 } from '@noble/hashes/sha256'; +import { hmac } from '@noble/hashes/hmac'; +import { cbc } from '@noble/ciphers/aes'; +import ecc from '../blue_modules/noble_ecc'; +import { parse } from 'url'; // eslint-disable-line n/no-deprecated-api +import { fetch } from '../util/fetch'; +import { base64ToUint8Array, hexToUint8Array, uint8ArrayToHex, uint8ArrayToString } from '../blue_modules/uint8array-extras'; + +const ONION_REGEX = /^(http:\/\/[^/:@]+\.onion(?::\d{1,5})?)(\/.*)?$/; // regex for onion URL + +interface LnurlPayServicePayload { + callback: string; + fixed: boolean; + min: number; + max: number; + domain: string; + metadata: string; + description?: string; + image?: string; + amount: number; + commentAllowed?: number; +} + +interface LnurlPayServiceBolt11Payload { + pr: string; + successAction?: any; + disposable?: boolean; + tag: string; + metadata: any; + minSendable: number; + maxSendable: number; + callback: string; + commentAllowed: number; +} + +interface DecodedInvoice { + destination: string; + num_satoshis: string; + num_millisatoshis: string; + timestamp: string; + fallback_addr: string; + route_hints: any[]; + payment_hash?: string; + description_hash?: string; + cltv_expiry?: string; + expiry?: string; + description?: string; +} + +/** + * @see https://github.com/btcontract/lnurl-rfc/blob/master/lnurl-pay.md + */ +export default class Lnurl { + static TAG_PAY_REQUEST = 'payRequest'; // type of LNURL + static TAG_WITHDRAW_REQUEST = 'withdrawRequest'; // type of LNURL + static TAG_LOGIN_REQUEST = 'login'; // type of LNURL + + private _lnurl: string; + private _lnurlPayServiceBolt11Payload: LnurlPayServiceBolt11Payload | false; + private _lnurlPayServicePayload: LnurlPayServicePayload | false; + private _AsyncStorage: any; + private _preimage: string | false; + + constructor(url: string | false, AsyncStorage?: any) { + this._lnurl = url || ''; + this._lnurlPayServiceBolt11Payload = false; + this._lnurlPayServicePayload = false; + this._AsyncStorage = AsyncStorage; + this._preimage = false; + } + + static findlnurl(bodyOfText: string): string | null { + const res = /^(?:http.*[&?]lightning=|lightning:)?(lnurl1[02-9ac-hj-np-z]+)/.exec(bodyOfText.toLowerCase()); + if (res) { + return res[1]; + } + return null; + } + + static getUrlFromLnurl(lnurlExample: string): string | false { + const found = Lnurl.findlnurl(lnurlExample); + if (!found) { + if (Lnurl.isLightningAddress(lnurlExample)) { + const username = lnurlExample.split('@')[0].trim(); + const host = lnurlExample.split('@')[1].trim(); + const proto = host.match(/\.onion$/) ? 'http' : 'https'; + return `${proto}://${host}/.well-known/lnurlp/${username}`; + } else { + return false; + } + } + + const decoded = bech32.decode(found, 10000); + return uint8ArrayToString(new Uint8Array(bech32.fromWords(decoded.words))); + } + + static isLnurl(url: string): boolean { + return Lnurl.findlnurl(url) !== null; + } + + static isOnionUrl(url: string): boolean { + return Lnurl.parseOnionUrl(url) !== null; + } + + static parseOnionUrl(url: string): [string, string] | null { + const match = url.match(ONION_REGEX); + if (match === null) return null; + const [, baseURI, path] = match; + return [baseURI, path]; + } + + async fetchGet(url: string): Promise { + const resp = await fetch(url, { method: 'GET' }); + if (resp.status >= 300) { + throw new Error('Bad response from server'); + } + const reply = await resp.json(); + if (reply.status === 'ERROR') { + throw new Error('Reply from server: ' + reply.reason); + } + return reply; + } + + decodeInvoice(invoice: string): DecodedInvoice { + const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); + + const decoded: DecodedInvoice = { + destination: payeeNodeKey ?? '', + num_satoshis: satoshis ? satoshis.toString() : '0', + num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0', + timestamp: timestamp?.toString() ?? '', + fallback_addr: '', + route_hints: [], + }; + + for (let i = 0; i < tags.length; i++) { + const { tagName, data } = tags[i]; + switch (tagName) { + case 'payment_hash': + decoded.payment_hash = String(data); + break; + case 'purpose_commit_hash': + decoded.description_hash = String(data); + break; + case 'min_final_cltv_expiry': + decoded.cltv_expiry = data.toString(); + break; + case 'expire_time': + decoded.expiry = data.toString(); + break; + case 'description': + decoded.description = String(data); + break; + } + } + + if (!decoded.expiry) decoded.expiry = '3600'; // default + + if (parseInt(decoded.num_satoshis, 10) === 0 && parseInt(decoded.num_millisatoshis, 10) > 0) { + decoded.num_satoshis = (parseInt(decoded.num_millisatoshis, 10) / 1000).toString(); + } + + return decoded; + } + + async requestBolt11FromLnurlPayService(amountSat: number, comment: string = ''): Promise { + if (!this._lnurlPayServicePayload) throw new Error('this._lnurlPayServicePayload is not set'); + if (!this._lnurlPayServicePayload.callback) throw new Error('this._lnurlPayServicePayload.callback is not set'); + if (amountSat < this._lnurlPayServicePayload.min || amountSat > this._lnurlPayServicePayload.max) + throw new Error( + 'The specified amount is invalid, ' + + amountSat + + ' it should be between ' + + this._lnurlPayServicePayload.min + + ' and ' + + this._lnurlPayServicePayload.max, + ); + const nonce = Math.floor(Math.random() * 2e16).toString(16); + const separator = this._lnurlPayServicePayload.callback.indexOf('?') === -1 ? '?' : '&'; + if (this.getCommentAllowed() && comment && comment.length > (this.getCommentAllowed() as number)) { + comment = comment.substr(0, this.getCommentAllowed() as number); + } + if (comment) comment = `&comment=${encodeURIComponent(comment)}`; + const urlToFetch = + this._lnurlPayServicePayload.callback + separator + 'amount=' + Math.floor(amountSat * 1000) + '&nonce=' + nonce + comment; + this._lnurlPayServiceBolt11Payload = (await this.fetchGet(urlToFetch)) as LnurlPayServiceBolt11Payload; + + // check pr description_hash, amount etc: + const decoded = this.decodeInvoice(this._lnurlPayServiceBolt11Payload.pr); + const metadataHash = uint8ArrayToHex(sha256(this._lnurlPayServicePayload.metadata)); + if (metadataHash !== decoded.description_hash) { + console.log(`Invoice description_hash doesn't match metadata.`); + } + if (parseInt(decoded.num_satoshis, 10) !== Math.round(amountSat)) { + throw new Error(`Invoice doesn't match specified amount, got ${decoded.num_satoshis}, expected ${Math.round(amountSat)}`); + } + + return this._lnurlPayServiceBolt11Payload; + } + + async callLnurlPayService(): Promise { + if (!this._lnurl) throw new Error('this._lnurl is not set'); + const url = Lnurl.getUrlFromLnurl(this._lnurl); + if (!url) throw new Error('Invalid LNURL'); + // calling the url + const reply = (await this.fetchGet(url)) as LnurlPayServiceBolt11Payload; + + if (reply.tag !== Lnurl.TAG_PAY_REQUEST) { + throw new Error('lnurl-pay expected, found tag ' + reply.tag); + } + + const data = reply; + + // parse metadata and extract things from it + let image: string | undefined; + let description: string | undefined; + const kvs = JSON.parse(data.metadata); + for (let i = 0; i < kvs.length; i++) { + const [k, v] = kvs[i]; + switch (k) { + case 'text/plain': + description = v; + break; + case 'image/png;base64': + case 'image/jpeg;base64': + image = 'data:' + k + ',' + v; + break; + } + } + + // setting the payment screen with the parameters + const min = Math.ceil((data.minSendable ?? 0) / 1000); + const max = Math.floor((data.maxSendable ?? 0) / 1000); + + this._lnurlPayServicePayload = { + callback: data.callback, + fixed: min === max, + min, + max, + // @ts-ignore idk + domain: data.callback.match(/^(https|http):\/\/([^/]+)\//)[2], + metadata: data.metadata, + description, + image, + amount: min, + commentAllowed: data.commentAllowed, + // lnurl: uri, + }; + return this._lnurlPayServicePayload; + } + + async loadSuccessfulPayment(paymentHash: string): Promise { + if (!paymentHash) throw new Error('No paymentHash provided'); + let data; + try { + data = await this._AsyncStorage.getItem('lnurlpay_success_data_' + paymentHash); + data = JSON.parse(data); + } catch (_) { + return false; + } + + if (!data) return false; + + this._lnurlPayServicePayload = data.lnurlPayServicePayload; + this._lnurlPayServiceBolt11Payload = data.lnurlPayServiceBolt11Payload; + this._lnurl = data.lnurl; + this._preimage = data.preimage; + + return true; + } + + async storeSuccess(paymentHash: string, preimage: string | { data: Buffer }): Promise { + if (typeof preimage === 'object') { + preimage = uint8ArrayToHex(new Uint8Array(preimage.data)); + } + this._preimage = preimage; + + await this._AsyncStorage.setItem( + 'lnurlpay_success_data_' + paymentHash, + JSON.stringify({ + lnurlPayServicePayload: this._lnurlPayServicePayload, + lnurlPayServiceBolt11Payload: this._lnurlPayServiceBolt11Payload, + lnurl: this._lnurl, + preimage, + }), + ); + } + + getSuccessAction(): any | undefined { + return this._lnurlPayServiceBolt11Payload && 'successAction' in this._lnurlPayServiceBolt11Payload + ? this._lnurlPayServiceBolt11Payload.successAction + : undefined; + } + + getDomain(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.domain : undefined; + } + + getDescription(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.description : undefined; + } + + getImage(): string | undefined { + return this._lnurlPayServicePayload ? this._lnurlPayServicePayload.image : undefined; + } + + getLnurl(): string { + return this._lnurl; + } + + getDisposable(): boolean | undefined { + return this._lnurlPayServiceBolt11Payload && 'disposable' in this._lnurlPayServiceBolt11Payload + ? this._lnurlPayServiceBolt11Payload.disposable + : undefined; + } + + getPreimage(): string | false { + return this._preimage; + } + + static decipherAES(ciphertextBase64: string, preimageHex: string, ivBase64: string): string { + // crypto-js's old implementation silently returned '' on malformed + // ciphertext (non-16-aligned bytes, bad PKCS7 padding) and threw on + // malformed UTF-8 plaintext. @noble/ciphers throws on the former. We + // catch every throw and return '' — the call site at + // screen/lnd/lnurlPaySuccess.tsx renders this directly without a + // try/catch, so a misbehaving LNURL server should not crash the screen. + // Note: unlike crypto-js's strict `enc.Utf8` decoder, `uint8ArrayToString` + // is lenient on bad UTF-8 (mojibake instead of throw); this is strictly + // safer than the old behaviour for this user-facing path. + try { + const key = hexToUint8Array(preimageHex); + const iv = base64ToUint8Array(ivBase64); + const ct = base64ToUint8Array(ciphertextBase64); + const pt = cbc(key, iv).decrypt(ct); + return uint8ArrayToString(pt); + } catch (_) { + return ''; + } + } + + getCommentAllowed(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.commentAllowed ? parseInt(this._lnurlPayServicePayload.commentAllowed.toString(), 10) : false; + } + + getMin(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.min ? parseInt(this._lnurlPayServicePayload.min.toString(), 10) : false; + } + + getMax(): number | false { + if (!this._lnurlPayServicePayload) return false; + return this._lnurlPayServicePayload.max ? parseInt(this._lnurlPayServicePayload.max.toString(), 10) : false; + } + + getAmount(): number | false { + return this.getMin(); + } + + async authenticate(secret: string): Promise { + if (!this._lnurl) throw new Error('this._lnurl is not set'); + + const url = parse(Lnurl.getUrlFromLnurl(this._lnurl) || '', true); + + if (!url.hostname) { + throw new Error('Invalid URL: hostname is null'); + } + + const privateKey = hmac(sha256, secret, url.hostname); + const publicKey = ecc.pointFromScalar(privateKey); + if (!publicKey) { + throw new Error('Failed to generate public key'); + } + const signature = ecc.signDER(hexToUint8Array(url.query.k1 as string), privateKey); + + const reply = await this.fetchGet(`${url.href}&sig=${uint8ArrayToHex(signature)}&key=${uint8ArrayToHex(publicKey)}`); + if (reply.status === 'OK') { + // Authentication successful + } else { + throw reply.reason; + } + } + + static isLightningAddress(address: string) { + // ensure only 1 `@` present: + if (address.split('@').length !== 2) return false; + const splitted = address.split('@'); + return !!splitted[0].trim() && !!splitted[1].trim(); + } +} diff --git a/class/measure.ts b/class/measure.ts new file mode 100644 index 00000000000..a9f907ed573 --- /dev/null +++ b/class/measure.ts @@ -0,0 +1,18 @@ +/** + * Simple helper to measure execution time of a code block + */ +export class Measure { + private _label: string; + private _start: number; + + constructor(label: string) { + this._label = label; + this._start = Date.now(); + } + + public end() { + const end = Date.now(); + const duration = Number(((end - this._start) / 1000).toFixed(3)); + console.log(`${this._label} took ${duration}s`); + } +} diff --git a/class/multisig-cosigner.js b/class/multisig-cosigner.js deleted file mode 100644 index bf15b24f070..00000000000 --- a/class/multisig-cosigner.js +++ /dev/null @@ -1,220 +0,0 @@ -import b58 from 'bs58check'; -import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../blue_modules/noble_ecc'; -const bip32 = BIP32Factory(ecc); - -export class MultisigCosigner { - constructor(data) { - this._data = data; - this._fp = false; - this._xpub = false; - this._path = false; - this._valid = false; - this._cosigners = []; - - // is it plain simple Zpub/Ypub/xpub? - if (data.startsWith('Zpub') && MultisigCosigner.isXpubValid(data)) { - this._fp = '00000000'; - this._xpub = data; - this._path = "m/48'/0'/0'/2'"; - this._valid = true; - this._cosigners = [true]; - return; - } else if (data.startsWith('Ypub') && MultisigCosigner.isXpubValid(data)) { - this._fp = '00000000'; - this._xpub = data; - this._path = "m/48'/0'/0'/1'"; - this._valid = true; - this._cosigners = [true]; - return; - } else if (data.startsWith('xpub') && MultisigCosigner.isXpubValid(data)) { - this._fp = '00000000'; - this._xpub = data; - this._path = "m/45'"; - this._valid = true; - this._cosigners = [true]; - return; - } - - // is it wallet descriptor? - if (data.startsWith('[')) { - const end = data.indexOf(']'); - const part = data.substr(1, end - 1).replace(/[h]/g, "'"); - this._fp = part.split('/')[0]; - const xpub = data.substr(end + 1); - - if (MultisigCosigner.isXpubValid(xpub)) { - this._xpub = xpub; - this._path = 'm'; - for (let c = 0; c < part.split('/').length; c++) { - if (c === 0) continue; - this._path += '/' + part.split('/')[c]; - } - this._cosigners = [true]; - this._valid = true; - return; - } - } - - // is it cobo json? - try { - const json = JSON.parse(data); - if (json.xfp && json.xpub && json.path) { - this._fp = json.xfp; - this._xpub = json.xpub; - this._path = json.path; - this._cosigners = [true]; - this._valid = true; - - // a bit more logic here: according to the formal BIP48 spec, this xpub field _can_ start with 'xpub', but - // the actual type of segwit can be inferred from the path - if ( - this._xpub.startsWith('xpub') && - [MultisigHDWallet.PATH_NATIVE_SEGWIT, MultisigHDWallet.PATH_WRAPPED_SEGWIT].includes(this._path) - ) { - const w = new MultisigHDWallet(); - w.addCosigner(this._xpub, '00000000', this._path); - w.setDerivationPath(this._path); - this._xpub = w.convertXpubToMultisignatureXpub(this._xpub); - } - - return; - } - } catch (_) { - this._valid = false; - } - - // is it cobo crypto-account URv2 ? - try { - const json = JSON.parse(data); - if (json && json.ExtPubKey && json.MasterFingerprint && json.AccountKeyPath) { - this._fp = json.MasterFingerprint; - this._xpub = json.ExtPubKey; - this._path = json.AccountKeyPath; - this._cosigners = [true]; - this._valid = true; - return; - } - } catch (_) { - this._valid = false; - } - - // is it coldcard json? - try { - const json = JSON.parse(data); - if (json.p2sh && json.p2sh_deriv && json.xfp) { - const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, json.p2sh_deriv)); - this._valid = true; - this._cosigners.push(cc); - } - - if (json.p2wsh_p2sh && json.p2wsh_p2sh_deriv && json.xfp) { - const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh_p2sh, json.p2wsh_p2sh_deriv)); - this._valid = true; - this._cosigners.push(cc); - } - - if (json.p2wsh && json.p2wsh_deriv && json.xfp) { - const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, json.p2wsh_deriv)); - this._valid = true; - this._cosigners.push(cc); - } - } catch (_) { - this._valid = false; - } - } - - static isXpubValid(key) { - let xpub; - - try { - const tempWallet = new MultisigHDWallet(); - xpub = tempWallet._zpubToXpub(key); - bip32.fromBase58(xpub); - return true; - } catch (_) {} - - return false; - } - - static exportToJson(xfp, xpub, path) { - return JSON.stringify({ - xfp, - xpub, - path, - }); - } - - isValid() { - return this._valid; - } - - getFp() { - return this._fp; - } - - getXpub() { - return this._xpub; - } - - getPath() { - return this._path; - } - - howManyCosignersWeHave() { - return this._cosigners.length; - } - - /** - * - * @returns {Array.} - */ - getAllCosigners() { - return this._cosigners; - } - - isNativeSegwit() { - return this.getXpub().startsWith('Zpub'); - } - - isWrappedSegwit() { - return this.getXpub().startsWith('Ypub'); - } - - isLegacy() { - return this.getXpub().startsWith('xpub'); - } - - getChainCodeHex() { - let data = b58.decode(this.getXpub()); - data = data.slice(4); - data = data.slice(1); - data = data.slice(4); - data = data.slice(4, 36); - return data.toString('hex'); - } - - getKeyHex() { - let data = b58.decode(this.getXpub()); - data = data.slice(4); - data = data.slice(1); - data = data.slice(4); - data = data.slice(36); - return data.toString('hex'); - } - - getParentFingerprintHex() { - let data = b58.decode(this.getXpub()); - data = data.slice(4); - data = data.slice(1); - data = data.slice(0, 4); - return data.toString('hex'); - } - - getDepthNumber() { - let data = b58.decode(this.getXpub()); - data = data.slice(4, 5); - return data.readInt8(); - } -} diff --git a/class/multisig-cosigner.ts b/class/multisig-cosigner.ts new file mode 100644 index 00000000000..a0852d1dd54 --- /dev/null +++ b/class/multisig-cosigner.ts @@ -0,0 +1,270 @@ +import BIP32Factory from 'bip32'; +import b58 from 'bs58check'; + +import ecc from '../blue_modules/noble_ecc'; +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import assert from 'assert'; +const bip32 = BIP32Factory(ecc); + +export class MultisigCosigner { + private _data: string; + private _fp: string = ''; + private _xpub: string = ''; + private _path: string = ''; + private _valid: boolean = false; + private _cosigners: any[]; + + constructor(data: string) { + this._data = data; + this._cosigners = []; + + // is it plain simple Zpub/Ypub/xpub? + if (data.startsWith('Zpub') && MultisigCosigner.isXpubValid(data)) { + this._fp = '00000000'; + this._xpub = data; + this._path = "m/48'/0'/0'/2'"; + this._valid = true; + this._cosigners = [true]; + return; + } else if (data.startsWith('Ypub') && MultisigCosigner.isXpubValid(data)) { + this._fp = '00000000'; + this._xpub = data; + this._path = "m/48'/0'/0'/1'"; + this._valid = true; + this._cosigners = [true]; + return; + } else if (data.startsWith('xpub') && MultisigCosigner.isXpubValid(data)) { + this._fp = '00000000'; + this._xpub = data; + this._path = "m/45'"; + this._valid = true; + this._cosigners = [true]; + return; + } + + // is it wallet descriptor? + if (data.startsWith('[')) { + const end = data.indexOf(']'); + const part = data.substr(1, end - 1).replace(/[h]/g, "'"); + this._fp = part.split('/')[0]; + const xpub = data.substr(end + 1); + + if (MultisigCosigner.isXpubValid(xpub)) { + this._xpub = xpub; + this._path = 'm'; + for (let c = 0; c < part.split('/').length; c++) { + if (c === 0) continue; + this._path += '/' + part.split('/')[c]; + } + this._cosigners = [true]; + this._valid = true; + return; + } + } + + // is it cobo json? + try { + const json = JSON.parse(data); + if (json.xfp && json.xpub && json.path) { + this._fp = json.xfp; + this._xpub = json.xpub; + this._path = json.path; + this._cosigners = [true]; + this._valid = true; + + // a bit more logic here: according to the formal BIP48 spec, this xpub field _can_ start with 'xpub', but + // the actual type of segwit can be inferred from the path + assert(this._xpub); + if ( + this._xpub.startsWith('xpub') && + [MultisigHDWallet.PATH_NATIVE_SEGWIT, MultisigHDWallet.PATH_WRAPPED_SEGWIT].includes(this._path) + ) { + const w = new MultisigHDWallet(); + w.addCosigner(this._xpub, '00000000', this._path); + w.setDerivationPath(this._path); + this._xpub = w.convertXpubToMultisignatureXpub(this._xpub); + } + + return; + } + } catch (_) { + this._valid = false; + } + + // is it cobo crypto-account URv2 ? + try { + const json = JSON.parse(data); + if (json && json.ExtPubKey && json.MasterFingerprint && json.AccountKeyPath) { + this._fp = json.MasterFingerprint; + this._xpub = json.ExtPubKey; + this._path = json.AccountKeyPath; + this._cosigners = [true]; + this._valid = true; + return; + } + } catch (_) { + this._valid = false; + } + + // is it coldcard / unchained json? + try { + const json = JSON.parse(data); + + // p2wsh_p2sh (Coldcard), p2sh_p2wsh (Unchained) + // same script type with reversed naming + const xpub = json.p2wsh_p2sh || json.p2sh_p2wsh; + const path = (json.p2wsh_p2sh_deriv || json.p2sh_p2wsh_deriv)?.replace(/h/g, "'"); + const p2sh_deriv = json.p2sh_deriv?.replace(/h/g, "'"); + const p2wsh_deriv = json.p2wsh_deriv?.replace(/h/g, "'"); + + if (json.p2sh && p2sh_deriv && json.xfp) { + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2sh, p2sh_deriv)); + this._valid = true; + this._cosigners.push(cc); + } + + if (xpub && path && json.xfp) { + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, xpub, path)); + this._valid = true; + this._cosigners.push(cc); + } + + if (json.p2wsh && p2wsh_deriv && json.xfp) { + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(json.xfp, json.p2wsh, p2wsh_deriv)); + this._valid = true; + this._cosigners.push(cc); + } + } catch (_) { + this._valid = false; + } + + // is it coldcardQ json? + try { + const json = JSON.parse(data); + if (json && json.chain === 'BTC' && json.xfp && (json.bip48_1 || json.bip48_2 || json.bip45)) { + if (json.bip48_1) { + const path = json.bip48_1.deriv.replace(/h/g, "'"); + const xpub = json.bip48_1._pub || json.bip48_1.xpub; // ColdcardQ provides SLIP-0132 encoded _pub (Ypub/Zpub). Prefer it when present, fallback to xpub for legacy. + const xfp = json.xfp; + + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(xfp, xpub, path)); + this._valid = true; + this._cosigners.push(cc); + } + if (json.bip48_2) { + const path = json.bip48_2.deriv.replace(/h/g, "'"); + const xpub = json.bip48_2._pub || json.bip48_2.xpub; // ColdcardQ provides SLIP-0132 encoded _pub (Ypub/Zpub). Prefer it when present, fallback to xpub for legacy. + const xfp = json.xfp; + + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(xfp, xpub, path)); + this._valid = true; + this._cosigners.push(cc); + } + if (json.bip45) { + const path = json.bip45.deriv.replace(/h/g, "'"); + const xpub = json.bip45._pub || json.bip45.xpub; // ColdcardQ provides SLIP-0132 encoded _pub (Ypub/Zpub). Prefer it when present, fallback to xpub for legacy. + const xfp = json.xfp; + + const cc = new MultisigCosigner(MultisigCosigner.exportToJson(xfp, xpub, path)); + this._valid = true; + this._cosigners.push(cc); + } + } + } catch (_) { + this._valid = false; + } + } + + static isXpubValid(key: string) { + let xpub; + + try { + const tempWallet = new MultisigHDWallet(); + xpub = tempWallet._zpubToXpub(key); + bip32.fromBase58(xpub); + return true; + } catch (_) {} + + return false; + } + + static exportToJson(xfp: string, xpub: string, path: string) { + return JSON.stringify({ + xfp, + xpub, + path, + }); + } + + isValid() { + return this._valid; + } + + getFp() { + return this._fp; + } + + getXpub() { + return this._xpub; + } + + getPath() { + return this._path; + } + + howManyCosignersWeHave() { + return this._cosigners.length; + } + + /** + * + * @returns {Array.} + */ + getAllCosigners() { + return this._cosigners; + } + + isNativeSegwit() { + return this.getXpub().startsWith('Zpub'); + } + + isWrappedSegwit() { + return this.getXpub().startsWith('Ypub'); + } + + isLegacy() { + return this.getXpub().startsWith('xpub'); + } + + getChainCodeHex() { + let data = b58.decode(this.getXpub()); + data = data.slice(4); + data = data.slice(1); + data = data.slice(4); + data = data.slice(4, 36); + return data.toString('hex'); + } + + getKeyHex() { + let data = b58.decode(this.getXpub()); + data = data.slice(4); + data = data.slice(1); + data = data.slice(4); + data = data.slice(36); + return data.toString('hex'); + } + + getParentFingerprintHex() { + let data = b58.decode(this.getXpub()); + data = data.slice(4); + data = data.slice(1); + data = data.slice(0, 4); + return data.toString('hex'); + } + + getDepthNumber() { + let data = b58.decode(this.getXpub()); + data = data.slice(4, 5); + return data.readInt8(); + } +} diff --git a/class/on-app-launch.js b/class/on-app-launch.js deleted file mode 100644 index 18c5ca0add4..00000000000 --- a/class/on-app-launch.js +++ /dev/null @@ -1,45 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; -const BlueApp = require('../BlueApp'); - -export default class OnAppLaunch { - static STORAGE_KEY = 'ONAPP_LAUNCH_SELECTED_DEFAULT_WALLET_KEY'; - - static async isViewAllWalletsEnabled() { - try { - const selectedDefaultWallet = await AsyncStorage.getItem(OnAppLaunch.STORAGE_KEY); - return selectedDefaultWallet === '' || selectedDefaultWallet === null; - } catch (_e) { - return true; - } - } - - static async setViewAllWalletsEnabled(value) { - if (!value) { - const selectedDefaultWallet = await OnAppLaunch.getSelectedDefaultWallet(); - if (!selectedDefaultWallet) { - const firstWallet = BlueApp.getWallets()[0]; - await OnAppLaunch.setSelectedDefaultWallet(firstWallet.getID()); - } - } else { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, ''); - } - } - - static async getSelectedDefaultWallet() { - let selectedWallet = false; - try { - const selectedWalletID = JSON.parse(await AsyncStorage.getItem(OnAppLaunch.STORAGE_KEY)); - selectedWallet = BlueApp.getWallets().find(wallet => wallet.getID() === selectedWalletID); - if (!selectedWallet) { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, ''); - } - } catch (_e) { - return false; - } - return selectedWallet; - } - - static async setSelectedDefaultWallet(value) { - await AsyncStorage.setItem(OnAppLaunch.STORAGE_KEY, JSON.stringify(value)); - } -} diff --git a/class/payjoin-transaction.js b/class/payjoin-transaction.js deleted file mode 100644 index 33362be9254..00000000000 --- a/class/payjoin-transaction.js +++ /dev/null @@ -1,88 +0,0 @@ -import * as bitcoin from 'bitcoinjs-lib'; -import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; -import alert from '../components/Alert'; -import { ECPairFactory } from 'ecpair'; -import ecc from '../blue_modules/noble_ecc'; -const ECPair = ECPairFactory(ecc); - -const delay = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds)); - -// Implements IPayjoinClientWallet -// https://github.com/bitcoinjs/payjoin-client/blob/master/ts_src/wallet.ts -export default class PayjoinTransaction { - constructor(psbt, broadcast, wallet) { - this._psbt = psbt; - this._broadcast = broadcast; - this._wallet = wallet; - this._payjoinPsbt = false; - } - - async getPsbt() { - // Nasty hack to get this working for now - const unfinalized = this._psbt.clone(); - for (const [index, input] of unfinalized.data.inputs.entries()) { - delete input.finalScriptWitness; - - const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); - const wif = this._wallet._getWifForAddress(address); - const keyPair = ECPair.fromWIF(wif); - - unfinalized.signInput(index, keyPair); - } - - return unfinalized; - } - - /** - * Doesnt conform to spec but needed for user-facing wallet software to find out txid of payjoined transaction - * - * @returns {boolean|Psbt} - */ - getPayjoinPsbt() { - return this._payjoinPsbt; - } - - async signPsbt(payjoinPsbt) { - // Do this without relying on private methods - - for (const [index, input] of payjoinPsbt.data.inputs.entries()) { - const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); - try { - const wif = this._wallet._getWifForAddress(address); - const keyPair = ECPair.fromWIF(wif); - payjoinPsbt.signInput(index, keyPair).finalizeInput(index); - } catch (e) {} - } - this._payjoinPsbt = payjoinPsbt; - return this._payjoinPsbt; - } - - async broadcastTx(txHex) { - try { - const result = await this._broadcast(txHex); - if (!result) { - throw new Error(`Broadcast failed`); - } - return ''; - } catch (e) { - return 'Error: ' + e.message; - } - } - - async scheduleBroadcastTx(txHex, milliseconds) { - delay(milliseconds).then(async () => { - const result = await this.broadcastTx(txHex); - if (result === '') { - // TODO: Improve the wording of this error message - ReactNativeHapticFeedback.trigger('notificationError', { ignoreAndroidSystemSettings: false }); - alert('Something was wrong with the payjoin transaction, the original transaction sucessfully broadcast.'); - } - }); - } - - async isOwnOutputScript(outputScript) { - const address = bitcoin.address.fromOutputScript(outputScript); - - return this._wallet.weOwnAddress(address); - } -} diff --git a/class/payjoin-transaction.ts b/class/payjoin-transaction.ts new file mode 100644 index 00000000000..358ecaa1ac0 --- /dev/null +++ b/class/payjoin-transaction.ts @@ -0,0 +1,113 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import { ECPairFactory } from 'ecpair'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import ecc from '../blue_modules/noble_ecc'; +import presentAlert from '../components/Alert'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import assert from 'assert'; +import { uint8ArrayToHex } from '../blue_modules/uint8array-extras'; +const ECPair = ECPairFactory(ecc); + +const delay = (milliseconds: number) => new Promise(resolve => setTimeout(resolve, milliseconds)); + +// Implements IPayjoinClientWallet +// https://github.com/bitcoinjs/payjoin-client/blob/master/ts_src/wallet.ts +export default class PayjoinTransaction { + private _psbt: bitcoin.Psbt; + private _broadcast: (txhex: string) => Promise; + private _wallet: HDSegwitBech32Wallet; + private _payjoinPsbt: any; + + constructor(psbt: bitcoin.Psbt, broadcast: (txhex: string) => Promise, wallet: HDSegwitBech32Wallet) { + this._psbt = psbt; + this._broadcast = broadcast; + this._wallet = wallet; + this._payjoinPsbt = false; + } + + async getPsbt() { + // Nasty hack to get this working for now + const unfinalized = this._psbt.clone(); + for (const [index, input] of unfinalized.data.inputs.entries()) { + delete input.finalScriptWitness; + + assert(input.witnessUtxo, 'Internal error: input.witnessUtxo is not set'); + const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); + const wif = this._wallet._getWifForAddress(address); + const keyPair = ECPair.fromWIF(wif); + + unfinalized.signInput(index, keyPair); + } + + // now, since payjoin lib expects an older version of Psbt object (from bitcoinjs-lib v6), + // it expects `script` to be Buffer, and in v7 its actually uint8 array. + // lets monkey patch the cloned PSBT so it returns buffers, as expected: + const origclone = unfinalized.clone; + unfinalized.clone = () => { + const newPsbt = origclone.apply(unfinalized); + const original = newPsbt.txOutputs; + + Object.defineProperty(newPsbt, 'txOutputs', { + get() { + return original.map(o => ({ + ...o, + script: Buffer.from(uint8ArrayToHex(o.script), 'hex'), + })); + }, + }); + + return newPsbt; + }; + + return unfinalized; + } + + /** + * Doesnt conform to spec but needed for user-facing wallet software to find out txid of payjoined transaction + * + * @returns {Psbt} + */ + getPayjoinPsbt() { + return this._payjoinPsbt; + } + + async signPsbt(payjoinPsbt: bitcoin.Psbt) { + // Do this without relying on private methods + + for (const [index, input] of payjoinPsbt.data.inputs.entries()) { + assert(input.witnessUtxo, 'Internal error: input.witnessUtxo is not set'); + const address = bitcoin.address.fromOutputScript(input.witnessUtxo.script); + try { + const wif = this._wallet._getWifForAddress(address); + const keyPair = ECPair.fromWIF(wif); + payjoinPsbt.signInput(index, keyPair).finalizeInput(index); + } catch (e) {} + } + this._payjoinPsbt = payjoinPsbt; + return this._payjoinPsbt; + } + + async broadcastTx(txHex: string) { + try { + const result = await this._broadcast(txHex); + if (!result) { + throw new Error(`Broadcast failed`); + } + return ''; + } catch (e: any) { + return 'Error: ' + e.message; + } + } + + async scheduleBroadcastTx(txHex: string, milliseconds: number) { + delay(milliseconds).then(async () => { + const result = await this.broadcastTx(txHex); + if (result === '') { + // TODO: Improve the wording of this error message + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: 'Something was wrong with the payjoin transaction, the original transaction successfully broadcast.' }); + } + }); + } +} diff --git a/class/quick-actions.js b/class/quick-actions.js deleted file mode 100644 index 0414b6b6b5c..00000000000 --- a/class/quick-actions.js +++ /dev/null @@ -1,85 +0,0 @@ -import QuickActions from 'react-native-quick-actions'; -import { Platform } from 'react-native'; -import { formatBalance } from '../loc'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useContext, useEffect } from 'react'; -import { BlueStorageContext } from '../blue_modules/storage-context'; - -function DeviceQuickActions() { - DeviceQuickActions.STORAGE_KEY = 'DeviceQuickActionsEnabled'; - const { wallets, walletsInitialized, isStorageEncrypted, preferredFiatCurrency } = useContext(BlueStorageContext); - - useEffect(() => { - if (walletsInitialized) { - isStorageEncrypted() - .then(value => { - if (value) { - QuickActions.clearShortcutItems(); - } else { - setQuickActions(); - } - }) - .catch(() => QuickActions.clearShortcutItems()); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallets, walletsInitialized, preferredFiatCurrency]); - - DeviceQuickActions.setEnabled = (enabled = true) => { - return AsyncStorage.setItem(DeviceQuickActions.STORAGE_KEY, JSON.stringify(enabled)).then(() => { - if (!enabled) { - QuickActions.clearShortcutItems(); - } else { - setQuickActions(); - } - }); - }; - - DeviceQuickActions.popInitialAction = async () => { - const data = await QuickActions.popInitialAction(); - return data; - }; - - DeviceQuickActions.getEnabled = async () => { - try { - const isEnabled = await AsyncStorage.getItem(DeviceQuickActions.STORAGE_KEY); - if (isEnabled === null) { - await DeviceQuickActions.setEnabled(JSON.stringify(true)); - return true; - } - return !!JSON.parse(isEnabled); - } catch { - return true; - } - }; - - const setQuickActions = async () => { - if (await DeviceQuickActions.getEnabled()) { - QuickActions.isSupported((error, _supported) => { - if (error === null) { - const shortcutItems = []; - for (const wallet of wallets.slice(0, 4)) { - shortcutItems.push({ - type: 'Wallets', // Required - title: wallet.getLabel(), // Optional, if empty, `type` will be used instead - subtitle: - wallet.hideBalance || wallet.getBalance() <= 0 - ? '' - : formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), - userInfo: { - url: `bluewallet://wallet/${wallet.getID()}`, // Provide any custom data like deep linking URL - }, - icon: Platform.select({ android: 'quickactions', ios: 'bookmark' }), - }); - } - QuickActions.setShortcutItems(shortcutItems); - } - }); - } else { - QuickActions.clearShortcutItems(); - } - }; - - return null; -} - -export default DeviceQuickActions; diff --git a/class/quick-actions.windows.js b/class/quick-actions.windows.js deleted file mode 100644 index 842d0f50fa9..00000000000 --- a/class/quick-actions.windows.js +++ /dev/null @@ -1,15 +0,0 @@ -function DeviceQuickActions() { - DeviceQuickActions.STORAGE_KEY = 'DeviceQuickActionsEnabled'; - - DeviceQuickActions.setEnabled = () => {}; - - DeviceQuickActions.getEnabled = async () => { - return false; - }; - - DeviceQuickActions.popInitialAction = () => {}; - - return null; -} - -export default DeviceQuickActions; diff --git a/class/rng.js b/class/rng.js deleted file mode 100644 index 3ba7251ec0d..00000000000 --- a/class/rng.js +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @fileOverview creates an rng module that will bring all calls to 'crypto' - * into one place to try and prevent mistakes when touching the crypto code. - */ - -import crypto from 'crypto'; -// uses `crypto` module under nodejs/cli and shim under RN -// @see blue_modules/crypto.js - -/** - * Generate cryptographically secure random bytes using native api. - * @param {number} size The number of bytes of randomness - * @return {Promise.} The random bytes - */ -export async function randomBytes(size) { - return new Promise((resolve, reject) => { - crypto.randomBytes(size, (err, data) => { - if (err) reject(err); - else resolve(data); - }); - }); -} diff --git a/class/rng.ts b/class/rng.ts new file mode 100644 index 00000000000..88fed282ea5 --- /dev/null +++ b/class/rng.ts @@ -0,0 +1,22 @@ +/** + * @fileOverview creates an rng module that will bring all calls to 'crypto' + * into one place to try and prevent mistakes when touching the crypto code. + */ + +// React Native: entropy via global crypto.getRandomValues (polyfilled by react-native-get-random-values) + +/** + * Generate cryptographically secure random bytes using native api. + * @param {number} size The number of bytes of randomness + * @return {Promise.} The random bytes + */ +export async function randomBytes(size: number): Promise { + const g = globalThis as any; + const rnCrypto = g && g.crypto; + if (!rnCrypto || typeof rnCrypto.getRandomValues !== 'function') { + throw new Error('crypto.getRandomValues is not available'); + } + const bytes = new Uint8Array(size); + rnCrypto.getRandomValues(bytes); + return bytes; +} diff --git a/class/synced-async-storage.ts b/class/synced-async-storage.ts deleted file mode 100644 index dee8059812f..00000000000 --- a/class/synced-async-storage.ts +++ /dev/null @@ -1,161 +0,0 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; - -const SHA256 = require('crypto-js/sha256'); -const ENCHEX = require('crypto-js/enc-hex'); -const ENCUTF8 = require('crypto-js/enc-utf8'); -const AES = require('crypto-js/aes'); - -export default class SyncedAsyncStorage { - defaultBaseUrl = 'https://bytes-store.herokuapp.com'; - encryptionMarker = 'encrypted://'; - - namespace: string = ''; - encryptionKey: string = ''; - - constructor(entropy: string) { - if (!entropy) throw new Error('entropy not provided'); - - this.namespace = this.hashIt(this.hashIt('namespace' + entropy)); - this.encryptionKey = this.hashIt(this.hashIt('encryption' + entropy)); - } - - hashIt(arg: string) { - return ENCHEX.stringify(SHA256(arg)); - } - - encrypt(clearData: string): string { - return this.encryptionMarker + AES.encrypt(clearData, this.encryptionKey).toString(); - } - - decrypt(encryptedData: string | null, encryptionKey: string | null = null): string { - if (encryptedData === null) return ''; - if (!encryptedData.startsWith(this.encryptionMarker)) return encryptedData; - const bytes = AES.decrypt(encryptedData.replace(this.encryptionMarker, ''), encryptionKey || this.encryptionKey); - return bytes.toString(ENCUTF8); - } - - static assertEquals(a: any, b: any) { - if (a !== b) throw new Error('Assertion failed that ' + a + ' equals ' + b); - } - - static assertNotEquals(a: any, b: any) { - if (a === b) throw new Error('Assertion failed that ' + a + ' NOT equals ' + b); - } - - async selftest(): Promise { - const clear = 'text line to be encrypted'; - const encrypted = this.encrypt(clear); - - SyncedAsyncStorage.assertEquals(encrypted.startsWith(this.encryptionMarker), true); - SyncedAsyncStorage.assertNotEquals(clear, encrypted); - const decrypted = this.decrypt(encrypted); - SyncedAsyncStorage.assertEquals(clear, decrypted); - - SyncedAsyncStorage.assertEquals(this.decrypt(clear), clear); - - SyncedAsyncStorage.assertEquals( - this.decrypt( - 'encrypted://U2FsdGVkX19XQWgwS8q5XjQSQ19OmBsNax4k6NZOAsKFhCgw9sJFwb+qVYfqy6X5', - '3a013f391e59daf2f5074fa66652784d17511ea072d7a8329ff9bddf371932ab', - ), - 'text line to be encrypted', - ); - - return true; - } - - /** - * @param key {string} - * @param value {string} - * - * @return {string} New sequence number from remote - */ - async setItemRemote(key: string, value: string): Promise { - const that = this; - return new Promise(function (resolve, reject) { - fetch(that.defaultBaseUrl + '/namespace/' + that.namespace + '/' + key, { - method: 'POST', - headers: { - Accept: 'text/plain', - 'Content-Type': 'text/plain', - }, - body: value, - }) - .then(async response => { - const text = await response.text(); - console.log('saved, seq num:', text); - resolve(text); - }) - .catch(reason => reject(reason)); - }); - } - - async setItem(key: string, value: string) { - value = this.encrypt(value); - await AsyncStorage.setItem(this.namespace + '_' + key, value); - const newSeqNum = await this.setItemRemote(key, value); - const localSeqNum = await this.getLocalSeqNum(); - if (+localSeqNum > +newSeqNum) { - // some race condition during save happened..? - return; - } - await AsyncStorage.setItem(this.namespace + '_' + 'seqnum', newSeqNum); - } - - async getItemRemote(key: string) { - const response = await fetch(this.defaultBaseUrl + '/namespace/' + this.namespace + '/' + key); - return await response.text(); - } - - async getItem(key: string) { - return this.decrypt(await AsyncStorage.getItem(this.namespace + '_' + key)); - } - - async getAllKeysRemote(): Promise { - const response = await fetch(this.defaultBaseUrl + '/namespacekeys/' + this.namespace); - const text = await response.text(); - return text.split(','); - } - - async getAllKeys(): Promise { - return (await AsyncStorage.getAllKeys()) - .filter(key => key.startsWith(this.namespace + '_')) - .map(key => key.replace(this.namespace + '_', '')); - } - - async getLocalSeqNum() { - return (await AsyncStorage.getItem(this.namespace + '_' + 'seqnum')) || '0'; - } - - async purgeLocalStorage() { - if (!this.namespace) throw new Error('No namespace'); - const keys = (await AsyncStorage.getAllKeys()).filter(key => key.startsWith(this.namespace)); - for (const key of keys) { - await AsyncStorage.removeItem(key); - } - } - - /** - * Should be called at init. - * Checks remote sequence number, and if remote is ahead - we sync all keys with local storage. - */ - async synchronize() { - const response = await fetch(this.defaultBaseUrl + '/namespaceseq/' + this.namespace); - const remoteSeqNum = (await response.text()) || '0'; - const localSeqNum = await this.getLocalSeqNum(); - if (+remoteSeqNum > +localSeqNum) { - console.log('remote storage is ahead, need to sync;', +remoteSeqNum, '>', +localSeqNum); - - // sort to ensure channel_manager comes first - for (const key of (await this.getAllKeysRemote()).sort()) { - const value = await this.getItemRemote(key); - await AsyncStorage.setItem(this.namespace + '_' + key, value); - console.log('synced', key, 'to', value); - } - - await AsyncStorage.setItem(this.namespace + '_' + 'seqnum', remoteSeqNum); - } else { - console.log('storage is up-to-date, no need for sync'); - } - } -} diff --git a/class/wallet-descriptor.ts b/class/wallet-descriptor.ts new file mode 100644 index 00000000000..11438f1db73 --- /dev/null +++ b/class/wallet-descriptor.ts @@ -0,0 +1,10 @@ +export class WalletDescriptor { + static getDescriptor(fpHex: string, path: string, xpub: string): string { + switch (true) { + case path.startsWith("m/86'"): + return `tr([${fpHex.toLowerCase()}/${path.replace('m/', '')}]${xpub})`; + default: + throw new Error('Dont know how to make a descriptor'); + } + } +} diff --git a/class/wallet-gradient.js b/class/wallet-gradient.js deleted file mode 100644 index f6bd1db8d6e..00000000000 --- a/class/wallet-gradient.js +++ /dev/null @@ -1,146 +0,0 @@ -import { LegacyWallet } from './wallets/legacy-wallet'; -import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; -import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; -import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; -import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; -import { WatchOnlyWallet } from './wallets/watch-only-wallet'; -import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; -import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; -import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; -import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; -import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; -import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; -import { LightningLdkWallet } from './wallets/lightning-ldk-wallet'; -import { SLIP39LegacyP2PKHWallet, SLIP39SegwitP2SHWallet, SLIP39SegwitBech32Wallet } from './wallets/slip39-wallets'; -import { useTheme } from '@react-navigation/native'; - -export default class WalletGradient { - static hdSegwitP2SHWallet = ['#007AFF', '#0040FF']; - static hdSegwitBech32Wallet = ['#6CD9FC', '#44BEE5']; - static segwitBech32Wallet = ['#6CD9FC', '#44BEE5']; - static watchOnlyWallet = ['#474646', '#282828']; - static legacyWallet = ['#37E8C0', '#15BE98']; - static hdLegacyP2PKHWallet = ['#FD7478', '#E73B40']; - static hdLegacyBreadWallet = ['#fe6381', '#f99c42']; - static multisigHdWallet = ['#1ce6eb', '#296fc5', '#3500A2']; - static defaultGradients = ['#B770F6', '#9013FE']; - static lightningCustodianWallet = ['#F1AA07', '#FD7E37']; - static aezeedWallet = ['#8584FF', '#5351FB']; - static ldkWallet = ['#8584FF', '#5351FB']; - - static createWallet = () => { - const { colors } = useTheme(); - return colors.lightButton; - }; - - static gradientsFor(type) { - let gradient; - switch (type) { - case WatchOnlyWallet.type: - gradient = WalletGradient.watchOnlyWallet; - break; - case LegacyWallet.type: - gradient = WalletGradient.legacyWallet; - break; - case HDLegacyP2PKHWallet.type: - case HDLegacyElectrumSeedP2PKHWallet.type: - case SLIP39LegacyP2PKHWallet.type: - gradient = WalletGradient.hdLegacyP2PKHWallet; - break; - case HDLegacyBreadwalletWallet.type: - gradient = WalletGradient.hdLegacyBreadWallet; - break; - case HDSegwitP2SHWallet.type: - case SLIP39SegwitP2SHWallet.type: - gradient = WalletGradient.hdSegwitP2SHWallet; - break; - case HDSegwitBech32Wallet.type: - case HDSegwitElectrumSeedP2WPKHWallet.type: - case SLIP39SegwitBech32Wallet.type: - gradient = WalletGradient.hdSegwitBech32Wallet; - break; - case LightningCustodianWallet.type: - gradient = WalletGradient.lightningCustodianWallet; - break; - case SegwitBech32Wallet.type: - gradient = WalletGradient.segwitBech32Wallet; - break; - case MultisigHDWallet.type: - gradient = WalletGradient.multisigHdWallet; - break; - case HDAezeedWallet.type: - gradient = WalletGradient.aezeedWallet; - break; - case LightningLdkWallet.type: - gradient = WalletGradient.ldkWallet; - break; - default: - gradient = WalletGradient.defaultGradients; - break; - } - return gradient; - } - - static linearGradientProps(type) { - let props; - switch (type) { - case MultisigHDWallet.type: - /* Example - props = { start: { x: 0, y: 0 } }; - https://github.com/react-native-linear-gradient/react-native-linear-gradient - */ - break; - default: - break; - } - return props; - } - - static headerColorFor(type) { - let gradient; - switch (type) { - case WatchOnlyWallet.type: - gradient = WalletGradient.watchOnlyWallet; - break; - case LegacyWallet.type: - gradient = WalletGradient.legacyWallet; - break; - case HDLegacyP2PKHWallet.type: - case HDLegacyElectrumSeedP2PKHWallet.type: - case SLIP39LegacyP2PKHWallet.type: - gradient = WalletGradient.hdLegacyP2PKHWallet; - break; - case HDLegacyBreadwalletWallet.type: - gradient = WalletGradient.hdLegacyBreadWallet; - break; - case HDSegwitP2SHWallet.type: - case SLIP39SegwitP2SHWallet.type: - gradient = WalletGradient.hdSegwitP2SHWallet; - break; - case HDSegwitBech32Wallet.type: - case HDSegwitElectrumSeedP2WPKHWallet.type: - case SLIP39SegwitBech32Wallet.type: - gradient = WalletGradient.hdSegwitBech32Wallet; - break; - case SegwitBech32Wallet.type: - gradient = WalletGradient.segwitBech32Wallet; - break; - case MultisigHDWallet.type: - gradient = WalletGradient.multisigHdWallet; - break; - case LightningCustodianWallet.type: - gradient = WalletGradient.lightningCustodianWallet; - break; - case HDAezeedWallet.type: - gradient = WalletGradient.aezeedWallet; - break; - case LightningLdkWallet.type: - gradient = WalletGradient.ldkWallet; - break; - default: - gradient = WalletGradient.defaultGradients; - break; - } - return gradient[0]; - } -} diff --git a/class/wallet-gradient.ts b/class/wallet-gradient.ts new file mode 100644 index 00000000000..df55b342013 --- /dev/null +++ b/class/wallet-gradient.ts @@ -0,0 +1,87 @@ +import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './wallets/legacy-wallet'; +import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; // Missing import +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; +import { TaprootWallet } from './wallets/taproot-wallet.ts'; +import { LightningArkWallet } from './wallets/lightning-ark-wallet.ts'; + +export default class WalletGradient { + static hdSegwitP2SHWallet: string[] = ['#007AFF', '#0040FF']; + static hdSegwitBech32Wallet: string[] = ['#6CD9FC', '#44BEE5']; + static segwitBech32Wallet: string[] = ['#6CD9FC', '#44BEE5']; + static watchOnlyWallet: string[] = ['#474646', '#282828']; + static legacyWallet: string[] = ['#37E8C0', '#15BE98']; + static taprootWallet: string[] = ['#4DA337', '#326D28']; + static hdLegacyP2PKHWallet: string[] = ['#FD7478', '#E73B40']; + static hdLegacyBreadWallet: string[] = ['#fe6381', '#f99c42']; + static multisigHdWallet: string[] = ['#1ce6eb', '#296fc5', '#3500A2']; + static defaultGradients: string[] = ['#B770F6', '#9013FE']; + static lightningCustodianWallet: string[] = ['#F1AA07', '#FD7E37']; // Corrected property with missing colors + static aezeedWallet: string[] = ['#8584FF', '#5351FB']; + + static createWallet = () => { + return WalletGradient.defaultGradients[0]; + }; + + static gradientsFor(type: string): string[] { + let gradient: string[]; + switch (type) { + case WatchOnlyWallet.type: + gradient = WalletGradient.watchOnlyWallet; + break; + case LegacyWallet.type: + gradient = WalletGradient.legacyWallet; + break; + case TaprootWallet.type: + gradient = WalletGradient.taprootWallet; + break; + case HDLegacyP2PKHWallet.type: + case HDLegacyElectrumSeedP2PKHWallet.type: + case SLIP39LegacyP2PKHWallet.type: + gradient = WalletGradient.hdLegacyP2PKHWallet; + break; + case HDLegacyBreadwalletWallet.type: + gradient = WalletGradient.hdLegacyBreadWallet; + break; + case HDSegwitP2SHWallet.type: + case SLIP39SegwitP2SHWallet.type: + gradient = WalletGradient.hdSegwitP2SHWallet; + break; + case HDSegwitBech32Wallet.type: + case HDSegwitElectrumSeedP2WPKHWallet.type: + case SLIP39SegwitBech32Wallet.type: + gradient = WalletGradient.hdSegwitBech32Wallet; + break; + case SegwitBech32Wallet.type: + gradient = WalletGradient.segwitBech32Wallet; + break; + case MultisigHDWallet.type: + gradient = WalletGradient.multisigHdWallet; + break; + case HDAezeedWallet.type: + gradient = WalletGradient.aezeedWallet; + break; + case LightningArkWallet.type: + case LightningCustodianWallet.type: + gradient = WalletGradient.lightningCustodianWallet; + break; + default: + gradient = WalletGradient.defaultGradients; + break; + } + return gradient; + } + + static headerColorFor(type: string): string { + return WalletGradient.gradientsFor(type)[0]; + } +} diff --git a/class/wallet-import.js b/class/wallet-import.js deleted file mode 100644 index 5367980df41..00000000000 --- a/class/wallet-import.js +++ /dev/null @@ -1,430 +0,0 @@ -import wif from 'wif'; -import bip38 from 'bip38'; - -import { - HDAezeedWallet, - HDLegacyBreadwalletWallet, - HDLegacyElectrumSeedP2PKHWallet, - HDLegacyP2PKHWallet, - HDSegwitBech32Wallet, - HDSegwitElectrumSeedP2WPKHWallet, - HDSegwitP2SHWallet, - LegacyWallet, - LightningCustodianWallet, - LightningLdkWallet, - MultisigHDWallet, - SLIP39LegacyP2PKHWallet, - SLIP39SegwitBech32Wallet, - SLIP39SegwitP2SHWallet, - SegwitBech32Wallet, - SegwitP2SHWallet, - WatchOnlyWallet, -} from '.'; -import loc from '../loc'; -import bip39WalletFormats from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json -import bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json'; - -// https://github.com/bitcoinjs/bip32/blob/master/ts-src/bip32.ts#L43 -export const validateBip32 = path => path.match(/^(m\/)?(\d+'?\/)*\d+'?$/) !== null; - -/** - * Function that starts wallet search and import process. It has async generator inside, so - * that the process can be stoped at any time. It reporst all the progress through callbacks. - * - * @param askPassphrase {bool} If true import process will call onPassword callback for wallet with optional password. - * @param searchAccounts {bool} If true import process will scan for all known derivation path from bip39_wallet_formats.json. If false it will use limited version. - * @param onProgress {function} Callback to report scanning progress - * @param onWallet {function} Callback to report wallet found - * @param onPassword {function} Callback to ask for password if needed - * @returns {{promise: Promise, stop: function}} - */ -const startImport = (importTextOrig, askPassphrase = false, searchAccounts = false, onProgress, onWallet, onPassword) => { - // state - let promiseResolve; - let promiseReject; - let running = true; // if you put it to false, internal generator stops - const wallets = []; - const promise = new Promise((resolve, reject) => { - promiseResolve = resolve; - promiseReject = reject; - }); - - // actions - const reportProgress = name => { - onProgress(name); - }; - const reportFinish = (cancelled, stopped) => { - promiseResolve({ cancelled, stopped, wallets }); - }; - const reportWallet = wallet => { - if (wallets.some(w => w.getID() === wallet.getID())) return; // do not add duplicates - wallets.push(wallet); - onWallet(wallet); - }; - const stop = () => (running = false); - - async function* importGenerator() { - // The plan: - // -3. ask for password, if needed and validate it - // -2. check if BIP38 encrypted - // -1a. check if multisig - // -1. check lightning custodian - // 0. check if its HDSegwitBech32Wallet (BIP84) - // 1. check if its HDSegwitP2SHWallet (BIP49) - // 2. check if its HDLegacyP2PKHWallet (BIP44) - // 3. check if its HDLegacyBreadwalletWallet (no BIP, just "m/0") - // 3.1 check HD Electrum legacy - // 3.2 check if its AEZEED - // 3.3 check if its SLIP39 - // 4. check if its Segwit WIF (P2SH) - // 5. check if its Legacy WIF - // 6. check if its address (watch-only wallet) - // 7. check if its private key (segwit address P2SH) TODO - // 7. check if its private key (legacy address) TODO - // 8. check if its a json array from BC-UR with multiple accounts - let text = importTextOrig.trim(); - let password; - - // BIP38 password required - if (text.startsWith('6P')) { - do { - password = await onPassword(loc.wallets.looks_like_bip38, loc.wallets.enter_bip38_password); - } while (!password); - } - - // HD BIP39 wallet password is optinal - const hd = new HDSegwitBech32Wallet(); - hd.setSecret(text); - if (askPassphrase && hd.validateMnemonic()) { - password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); - } - - // AEZEED password needs to be correct - const aezeed = new HDAezeedWallet(); - aezeed.setSecret(text); - if (await aezeed.mnemonicInvalidPassword()) { - do { - password = await onPassword('', loc.wallets.enter_bip38_password); - aezeed.setPassphrase(password); - } while (await aezeed.mnemonicInvalidPassword()); - } - - // SLIP39 wallet password is optinal - if (askPassphrase && text.includes('\n')) { - const s1 = new SLIP39SegwitP2SHWallet(); - s1.setSecret(text); - - if (s1.validateMnemonic()) { - password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); - } - } - - // ELECTRUM segwit wallet password is optinal - const electrum1 = new HDSegwitElectrumSeedP2WPKHWallet(); - electrum1.setSecret(text); - if (askPassphrase && electrum1.validateMnemonic()) { - password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); - } - - // ELECTRUM legacy wallet password is optinal - const electrum2 = new HDLegacyElectrumSeedP2PKHWallet(); - electrum2.setSecret(text); - if (askPassphrase && electrum2.validateMnemonic()) { - password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); - } - - // is it bip38 encrypted - if (text.startsWith('6P')) { - const decryptedKey = await bip38.decryptAsync(text, password); - - if (decryptedKey) { - text = wif.encode(0x80, decryptedKey.privateKey, decryptedKey.compressed); - } - } - - // is it multisig? - yield { progress: 'multisignature' }; - const ms = new MultisigHDWallet(); - ms.setSecret(text); - if (ms.getN() > 0 && ms.getM() > 0) { - await ms.fetchBalance(); - yield { wallet: ms }; - } - - // is it lightning custodian? - yield { progress: 'lightning custodian' }; - if (text.startsWith('blitzhub://') || text.startsWith('lndhub://')) { - const lnd = new LightningCustodianWallet(); - if (text.includes('@')) { - const split = text.split('@'); - lnd.setBaseURI(split[1]); - lnd.setSecret(split[0]); - } - await lnd.init(); - await lnd.authorize(); - await lnd.fetchTransactions(); - await lnd.fetchUserInvoices(); - await lnd.fetchPendingTransactions(); - await lnd.fetchBalance(); - yield { wallet: lnd }; - } - - // is it LDK? - yield { progress: 'lightning' }; - if (text.startsWith('ldk://')) { - const ldk = new LightningLdkWallet(); - ldk.setSecret(text); - if (ldk.valid()) { - await ldk.init(); - yield { wallet: ldk }; - } - } - - // check bip39 wallets - yield { progress: 'bip39' }; - const hd2 = new HDSegwitBech32Wallet(); - hd2.setSecret(text); - hd2.setPassphrase(password); - if (hd2.validateMnemonic()) { - let walletFound = false; - // by default we don't try all the paths and options - const searchPaths = searchAccounts ? bip39WalletFormats : bip39WalletFormatsBlueWallet; - for (const i of searchPaths) { - // we need to skip m/0' p2pkh from default scan list. It could be a BRD wallet and will be handled later - if (i.derivation_path === "m/0'" && i.script_type === 'p2pkh') continue; - let paths; - if (i.iterate_accounts && searchAccounts) { - const basicPath = i.derivation_path.slice(0, -2); // remove 0' from the end - paths = [...Array(10).keys()].map(j => basicPath + j + "'"); // add account number - } else { - paths = [i.derivation_path]; - } - let WalletClass; - switch (i.script_type) { - case 'p2pkh': - WalletClass = HDLegacyP2PKHWallet; - break; - case 'p2wpkh-p2sh': - WalletClass = HDSegwitP2SHWallet; - break; - default: - // p2wpkh - WalletClass = HDSegwitBech32Wallet; - } - for (const path of paths) { - const wallet = new WalletClass(); - wallet.setSecret(text); - wallet.setPassphrase(password); - wallet.setDerivationPath(path); - yield { progress: `bip39 ${i.script_type} ${path}` }; - if (await wallet.wasEverUsed()) { - yield { wallet }; - walletFound = true; - } else { - break; // don't check second account if first one is empty - } - } - } - - // m/0' p2pkh is a special case. It could be regular a HD wallet or a BRD wallet. - // to decide which one is it let's compare number of transactions - const m0Legacy = new HDLegacyP2PKHWallet(); - m0Legacy.setSecret(text); - m0Legacy.setPassphrase(password); - m0Legacy.setDerivationPath("m/0'"); - yield { progress: "bip39 p2pkh m/0'" }; - // BRD doesn't support passphrase and only works with 12 words seeds - if (!password && text.split(' ').length === 12) { - const brd = new HDLegacyBreadwalletWallet(); - brd.setSecret(text); - - if (await m0Legacy.wasEverUsed()) { - await m0Legacy.fetchBalance(); - await m0Legacy.fetchTransactions(); - yield { progress: 'BRD' }; - await brd.fetchBalance(); - await brd.fetchTransactions(); - if (brd.getTransactions().length > m0Legacy.getTransactions().length) { - yield { wallet: brd }; - } else { - yield { wallet: m0Legacy }; - } - walletFound = true; - } - } else { - if (await m0Legacy.wasEverUsed()) { - yield { wallet: m0Legacy }; - walletFound = true; - } - } - - // if we havent found any wallet for this seed suggest new bech32 wallet - if (!walletFound) { - yield { wallet: hd2 }; - } - // return; - } - - yield { progress: 'wif' }; - const segwitWallet = new SegwitP2SHWallet(); - segwitWallet.setSecret(text); - if (segwitWallet.getAddress()) { - // ok its a valid WIF - let walletFound = false; - - yield { progress: 'wif p2wpkh' }; - const segwitBech32Wallet = new SegwitBech32Wallet(); - segwitBech32Wallet.setSecret(text); - if (await segwitBech32Wallet.wasEverUsed()) { - // yep, its single-address bech32 wallet - await segwitBech32Wallet.fetchBalance(); - walletFound = true; - yield { wallet: segwitBech32Wallet }; - } - - yield { progress: 'wif p2wpkh-p2sh' }; - if (await segwitWallet.wasEverUsed()) { - // yep, its single-address p2wpkh wallet - await segwitWallet.fetchBalance(); - walletFound = true; - yield { wallet: segwitWallet }; - } - - // default wallet is Legacy - yield { progress: 'wif p2pkh' }; - const legacyWallet = new LegacyWallet(); - legacyWallet.setSecret(text); - if (await legacyWallet.wasEverUsed()) { - // yep, its single-address legacy wallet - await legacyWallet.fetchBalance(); - walletFound = true; - yield { wallet: legacyWallet }; - } - - // if no wallets was ever used, import all of them - if (!walletFound) { - yield { wallet: segwitBech32Wallet }; - yield { wallet: segwitWallet }; - yield { wallet: legacyWallet }; - } - } - - // case - WIF is valid, just has uncompressed pubkey - yield { progress: 'wif p2pkh' }; - const legacyWallet = new LegacyWallet(); - legacyWallet.setSecret(text); - if (legacyWallet.getAddress()) { - await legacyWallet.fetchBalance(); - await legacyWallet.fetchTransactions(); - yield { wallet: legacyWallet }; - } - - // maybe its a watch-only address? - yield { progress: 'watch only' }; - const watchOnly = new WatchOnlyWallet(); - watchOnly.setSecret(text); - if (watchOnly.valid()) { - await watchOnly.fetchBalance(); - yield { wallet: watchOnly }; - } - - // electrum p2wpkh-p2sh - yield { progress: 'electrum p2wpkh-p2sh' }; - const el1 = new HDSegwitElectrumSeedP2WPKHWallet(); - el1.setSecret(text); - el1.setPassphrase(password); - if (el1.validateMnemonic()) { - yield { wallet: el1 }; // not fetching txs or balances, fuck it, yolo, life is too short - } - - // electrum p2wpkh-p2sh - yield { progress: 'electrum p2pkh' }; - const el2 = new HDLegacyElectrumSeedP2PKHWallet(); - el2.setSecret(text); - el2.setPassphrase(password); - if (el2.validateMnemonic()) { - yield { wallet: el2 }; // not fetching txs or balances, fuck it, yolo, life is too short - } - - // is it AEZEED? - yield { progress: 'aezeed' }; - const aezeed2 = new HDAezeedWallet(); - aezeed2.setSecret(text); - aezeed2.setPassphrase(password); - if (await aezeed2.validateMnemonicAsync()) { - yield { wallet: aezeed2 }; // not fetching txs or balances, fuck it, yolo, life is too short - } - - // if it is multi-line string, then it is probably SLIP39 wallet - // each line - one share - yield { progress: 'SLIP39' }; - if (text.includes('\n')) { - const s1 = new SLIP39SegwitP2SHWallet(); - s1.setSecret(text); - - if (s1.validateMnemonic()) { - yield { progress: 'SLIP39 p2wpkh-p2sh' }; - s1.setPassphrase(password); - if (await s1.wasEverUsed()) { - yield { wallet: s1 }; - } - - yield { progress: 'SLIP39 p2pkh' }; - const s2 = new SLIP39LegacyP2PKHWallet(); - s2.setPassphrase(password); - s2.setSecret(text); - if (await s2.wasEverUsed()) { - yield { wallet: s2 }; - } - - yield { progress: 'SLIP39 p2wpkh' }; - const s3 = new SLIP39SegwitBech32Wallet(); - s3.setSecret(text); - s3.setPassphrase(password); - yield { wallet: s3 }; - } - } - - // is it BC-UR payload with multiple accounts? - yield { progress: 'BC-UR' }; - try { - const json = JSON.parse(text); - if (Array.isArray(json)) { - for (const account of json) { - if (account.ExtPubKey && account.MasterFingerprint && account.AccountKeyPath) { - const wallet = new WatchOnlyWallet(); - wallet.setSecret(JSON.stringify(account)); - wallet.init(); - yield { wallet }; - } - } - } - } catch (_) {} - } - - // POEHALI - (async () => { - const generator = importGenerator(); - while (true) { - const next = await generator.next(); - if (!running) throw new Error('Discovery stopped'); // break if stop() has been called - if (next.value?.progress) reportProgress(next.value.progress); - if (next.value?.wallet) reportWallet(next.value.wallet); - if (next.done) break; // break if generator has been finished - } - reportFinish(); - })().catch(e => { - if (e.message === 'Cancel Pressed') { - reportFinish(true); - return; - } else if (e.message === 'Discovery stopped') { - reportFinish(undefined, true); - return; - } - promiseReject(e); - }); - - return { promise, stop }; -}; - -export default startImport; diff --git a/class/wallet-import.ts b/class/wallet-import.ts new file mode 100644 index 00000000000..1cbbaac1e62 --- /dev/null +++ b/class/wallet-import.ts @@ -0,0 +1,649 @@ +import bip38 from 'bip38'; +import wif from 'wif'; + +import loc from '../loc'; +import { HDAezeedWallet } from './wallets/hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './wallets/hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './wallets/hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './wallets/hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './wallets/hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './wallets/hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './wallets/hd-segwit-p2sh-wallet'; +import { HDTaprootWallet } from './wallets/hd-taproot-wallet'; +import { LegacyWallet } from './wallets/legacy-wallet'; +import { LightningCustodianWallet } from './wallets/lightning-custodian-wallet'; +import { LightningArkWallet } from './wallets/lightning-ark-wallet'; +import { MultisigHDWallet } from './wallets/multisig-hd-wallet'; +import { SegwitBech32Wallet } from './wallets/segwit-bech32-wallet'; +import { SegwitP2SHWallet } from './wallets/segwit-p2sh-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './wallets/slip39-wallets'; +import { TaprootWallet } from './wallets/taproot-wallet'; +import { WatchOnlyWallet } from './wallets/watch-only-wallet'; +import bip39WalletFormatsElectrum from './bip39_wallet_formats.json'; // https://github.com/spesmilo/electrum/blob/master/electrum/bip39_wallet_formats.json +import bip39WalletFormatsBlueWallet from './bip39_wallet_formats_bluewallet.json'; +import type { TWallet } from './wallets/types'; + +// https://github.com/bitcoinjs/bip32/blob/master/ts-src/bip32.ts#L43 +export const validateBip32 = (path: string) => path.match(/^(m\/)?(\d+'?\/)*\d+'?$/) !== null; + +// because original file bip39WalletFormatsElectrum is from Electrum X and doesn't contain p2tr wallets, we need to add it +bip39WalletFormatsElectrum.push({ + description: 'Standard BIP86 native taproot', + derivation_path: "m/86'/0'/0'", + script_type: 'p2tr', + iterate_accounts: true, +}); + +type TStatus = { + cancelled: boolean; + stopped: boolean; + wallets: TWallet[]; +}; + +export type TImport = { + promise: Promise; + stop: () => void; +}; + +/** + * Function that starts wallet search and import process. It has async generator inside, so + * that the process can be stoped at any time. It reporst all the progress through callbacks. + * + * @param askPassphrase {boolean} If true import process will call onPassword callback for wallet with optional password. + * @param searchAccounts {boolean} If true import process will scan for all known derivation path from bip39_wallet_formats.json. If false it will use limited version. + * @param onProgress {function} Callback to report scanning progress + * @param onWallet {function} Callback to report wallet found + * @param onPassword {function} Callback to ask for password if needed + * @returns {{promise: Promise, stop: function}} + */ +const startImport = ( + importTextOrig: string, + askPassphrase: boolean = false, + searchAccounts: boolean = false, + offline: boolean = false, + onProgress: (name: string) => void, + onWallet: (wallet: TWallet) => void, + onPassword: (title: string, text: string) => Promise, +): TImport => { + // state + let promiseResolve: (arg: TStatus) => void; + let promiseReject: (reason?: any) => void; + let running = true; // if you put it to false, internal generator stops + const wallets: TWallet[] = []; + const promise = new Promise((resolve, reject) => { + promiseResolve = resolve; + promiseReject = reject; + }); + + // helpers + // in offline mode all wallets are considered used + const wasUsed = async (wallet: TWallet): Promise => { + if (offline) return true; + return wallet.wasEverUsed(); + }; + const fetch = async (wallet: TWallet, balance: boolean = false, transactions: boolean = false) => { + if (offline) return; + if (balance) await wallet.fetchBalance(); + if (transactions) await wallet.fetchTransactions(); + }; + + // actions + const reportProgress = (name: string) => { + onProgress(name); + }; + const reportFinish = (cancelled: boolean = false, stopped: boolean = false) => { + promiseResolve({ cancelled, stopped, wallets }); + }; + const reportWallet = (wallet: TWallet) => { + if (wallets.some(w => w.getID() === wallet.getID())) return; // do not add duplicates + wallets.push(wallet); + onWallet(wallet); + }; + const stop = () => (running = false); + + async function* importGenerator() { + // The plan: + // -3. ask for password, if needed and validate it + // -2. check if BIP38 encrypted + // -1a. check if multisig + // -1. check lightning custodian + // 0. check if its HDSegwitBech32Wallet (BIP84) + // 1. check if its HDSegwitP2SHWallet (BIP49) + // 2. check if its HDLegacyP2PKHWallet (BIP44) + // 3. check if its HDLegacyBreadwalletWallet (no BIP, just "m/0") + // 3.1 check HD Electrum legacy + // 3.2 check if its AEZEED + // 3.3 check if its SLIP39 + // 3.4 check if its HDTaprootWallet (BIP86) + // 4. check if its Segwit WIF (P2SH) + // 4.5 check if its Taproot WIF + // 5. check if its Legacy WIF + // 6. check if its address (watch-only wallet) + // 7. check if its private key (segwit address P2SH) TODO + // 7. check if its private key (legacy address) TODO + // 8. check if its a json array from BC-UR with multiple accounts + let text = importTextOrig.trim(); + let password; + + // BIP38 password required + if (text.startsWith('6P')) { + do { + password = await onPassword(loc.wallets.looks_like_bip38, loc.wallets.enter_bip38_password); + } while (!password); + } + + // HD BIP39 wallet password is optinal + const hd = new HDSegwitBech32Wallet(); + hd.setSecret(text); + if (askPassphrase && hd.validateMnemonic()) { + password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); + } + + // AEZEED password needs to be correct + const aezeed = new HDAezeedWallet(); + aezeed.setSecret(text); + if (await aezeed.mnemonicInvalidPassword()) { + do { + password = await onPassword('', loc.wallets.enter_bip38_password); + aezeed.setPassphrase(password); + } while (await aezeed.mnemonicInvalidPassword()); + } + + // SLIP39 wallet password is optinal + if (askPassphrase && text.includes('\n')) { + const s1 = new SLIP39SegwitP2SHWallet(); + s1.setSecret(text); + + if (s1.validateMnemonic()) { + password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); + } + } + + // ELECTRUM segwit wallet password is optinal + const electrum1 = new HDSegwitElectrumSeedP2WPKHWallet(); + electrum1.setSecret(text); + if (askPassphrase && electrum1.validateMnemonic()) { + password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); + } + + // ELECTRUM legacy wallet password is optinal + const electrum2 = new HDLegacyElectrumSeedP2PKHWallet(); + electrum2.setSecret(text); + if (askPassphrase && electrum2.validateMnemonic()) { + password = await onPassword(loc.wallets.import_passphrase_title, loc.wallets.import_passphrase_message); + } + + // is it bip38 encrypted + if (text.startsWith('6P') && password) { + const decryptedKey = await bip38.decryptAsync(text, password); + + if (decryptedKey) { + text = wif.encode(0x80, decryptedKey.privateKey, decryptedKey.compressed); + } + } + + // is it multisig? + yield { progress: 'multisignature' }; + const ms = new MultisigHDWallet(); + ms.setSecret(text); + if (ms.getN() > 0 && ms.getM() > 0) { + await fetch(ms, true, false); + yield { wallet: ms }; + } + + // is it lightning custodian? + yield { progress: 'lightning custodian' }; + if (text.startsWith('blitzhub://') || text.startsWith('lndhub://')) { + const lnd = new LightningCustodianWallet(); + if (text.includes('@')) { + const split = text.split('@'); + lnd.setBaseURI(split[1]); + lnd.setSecret(split[0]); + } + await lnd.init(); + if (!offline) { + await lnd.authorize(); + await lnd.fetchTransactions(); + await lnd.fetchUserInvoices(); + await lnd.fetchPendingTransactions(); + await lnd.fetchBalance(); + } + yield { wallet: lnd }; + } + + // is it lightning ark wallet? + yield { progress: 'lightning ark' }; + if (text.startsWith('arkade://')) { + const ark = new LightningArkWallet(); + ark.setSecret(text); + // Defer init() to first wallet open when offline — init touches the ASP + // and delegator over the network. We still detect the wallet by prefix + // and persist it with its secret. + // A network or SDK failure during init must not abort the import: the + // wallet type and secret are known, and the SDK runtime can be brought + // up the next time the wallet is opened. + if (!offline) { + try { + await ark.init(); + // Restore any previous Boltz swap activity for this seed exactly + // once, here at import time. We never run this on later wallet + // opens — the app does not sweep all swaps on bootstrap. A failure + // must not block the import: the wallet itself is fine, the + // restored rows are an optional bonus for imported-from-elsewhere + // wallets. + try { + await ark.restoreSwaps(); + } catch (e: any) { + console.log('[wallet-import] restoreSwaps failed:', e?.message ?? e); + } + try { + await ark.fetchBalance(); + await ark.fetchTransactions(); + } catch (e: any) { + console.log('[wallet-import] initial Ark sync failed:', e?.message ?? e); + } + } catch (e: any) { + console.log('[wallet-import] Ark init failed; deferring to next open:', e?.message ?? e); + } + } + yield { wallet: ark }; + } + + // check bip39 wallets + yield { progress: 'bip39' }; + const hd2 = new HDSegwitBech32Wallet(); + hd2.setSecret(text); + if (password) { + hd2.setPassphrase(password); + } + if (hd2.validateMnemonic()) { + let walletFound = false; + // by default we don't try all the paths and options + const searchPaths = searchAccounts ? bip39WalletFormatsElectrum : bip39WalletFormatsBlueWallet; + for (const i of searchPaths) { + // we need to skip m/0' p2pkh from default scan list. It could be a BRD wallet and will be handled later + if (i.derivation_path === "m/0'" && i.script_type === 'p2pkh') continue; + let paths; + if (i.iterate_accounts && searchAccounts) { + const basicPath = i.derivation_path.slice(0, -2); // remove 0' from the end + paths = [...Array(10).keys()].map(j => basicPath + j + "'"); // add account number + } else { + paths = [i.derivation_path]; + } + let WalletClass; + switch (i.script_type) { + case 'p2pkh': + WalletClass = HDLegacyP2PKHWallet; + break; + case 'p2wpkh-p2sh': + WalletClass = HDSegwitP2SHWallet; + break; + case 'p2tr': + WalletClass = HDTaprootWallet; + break; + default: + // p2wpkh + WalletClass = HDSegwitBech32Wallet; + } + for (const path of paths) { + const wallet = new WalletClass(); + wallet.setSecret(text); + if (password) { + wallet.setPassphrase(password); + } + wallet.setDerivationPath(path); + yield { progress: `bip39 ${i.script_type} ${path}` }; + if (await wasUsed(wallet)) { + yield { wallet }; + walletFound = true; + } else { + break; // don't check second account if first one is empty + } + } + } + + // m/0' p2pkh is a special case. It could be regular a HD wallet or a BRD wallet. + // to decide which one is it let's compare number of transactions + const m0Legacy = new HDLegacyP2PKHWallet(); + m0Legacy.setSecret(text); + if (password) { + m0Legacy.setPassphrase(password); + } + m0Legacy.setDerivationPath("m/0'"); + yield { progress: "bip39 p2pkh m/0'" }; + // BRD doesn't support passphrase and only works with 12 words seeds + // do not try to guess BRD wallet in offline mode + if (!password && text.split(' ').length === 12 && !offline) { + const brd = new HDLegacyBreadwalletWallet(); + brd.setSecret(text); + + if (await wasUsed(m0Legacy)) { + await m0Legacy.fetchBalance(); + await m0Legacy.fetchTransactions(); + yield { progress: 'BRD' }; + await brd.fetchBalance(); + await brd.fetchTransactions(); + if (brd.getTransactions().length > m0Legacy.getTransactions().length) { + yield { wallet: brd }; + } else { + yield { wallet: m0Legacy }; + } + walletFound = true; + } + } else { + if (await wasUsed(m0Legacy)) { + yield { wallet: m0Legacy }; + walletFound = true; + } + } + + // if we havent found any wallet for this seed suggest new bech32 wallet + if (!walletFound) { + yield { wallet: hd2 }; + } + } + + yield { progress: 'wif' }; + + const segwitWallet = new SegwitP2SHWallet(); + segwitWallet.setSecret(text); + if (segwitWallet.getAddress()) { + // ok its a valid WIF + let walletFound = false; + + yield { progress: 'wif p2wpkh' }; + const segwitBech32Wallet = new SegwitBech32Wallet(); + segwitBech32Wallet.setSecret(text); + if (await wasUsed(segwitBech32Wallet)) { + // yep, its single-address bech32 wallet + await fetch(segwitBech32Wallet, true); + walletFound = true; + yield { wallet: segwitBech32Wallet }; + } + + yield { progress: 'wif p2tr' }; + const taprootWallet = new TaprootWallet(); + taprootWallet.setSecret(text); + if (await wasUsed(taprootWallet)) { + // yep, its single-address taproot wallet + await fetch(taprootWallet, true); + walletFound = true; + yield { wallet: taprootWallet }; + } + + yield { progress: 'wif p2wpkh-p2sh' }; + if (await wasUsed(segwitWallet)) { + // yep, its single-address p2wpkh wallet + await fetch(segwitWallet, true); + walletFound = true; + yield { wallet: segwitWallet }; + } + + // default wallet is Legacy + yield { progress: 'wif p2pkh' }; + const legacyWallet = new LegacyWallet(); + legacyWallet.setSecret(text); + if (await wasUsed(legacyWallet)) { + // yep, its single-address legacy wallet + await fetch(legacyWallet, true); + walletFound = true; + yield { wallet: legacyWallet }; + } + + // if no wallets was ever used, import all of them + if (!walletFound) { + yield { wallet: segwitBech32Wallet }; + yield { wallet: segwitWallet }; + yield { wallet: legacyWallet }; + yield { wallet: taprootWallet }; + } + } + + // case - WIF is valid, just has uncompressed pubkey + yield { progress: 'wif p2pkh' }; + const legacyWallet = new LegacyWallet(); + legacyWallet.setSecret(text); + if (legacyWallet.getAddress()) { + await fetch(legacyWallet, true, true); + yield { wallet: legacyWallet }; + } + + yield { progress: 'Private key in hex/base64' }; + + // check if text is in hex or base64 format + const isHexKey = /^[0-9a-fA-F]{64}$/.test(text); + const isBase64Key = /^[A-Za-z0-9+/=]{43,44}$/.test(text); + + let rawKeyBuffer; + let privateKey; + + if (isHexKey) { + rawKeyBuffer = Buffer.from(text, 'hex'); + } else if (isBase64Key) { + rawKeyBuffer = Buffer.from(text, 'base64'); + } + + if (rawKeyBuffer && rawKeyBuffer.length === 32) { + let walletFound = false; + + // convert the bytes to Wallet import format, 0x80 for mainnet, + // start with uncompressed p2pkh + privateKey = wif.encode(0x80, rawKeyBuffer, false); + + yield { progress: 'p2pkh uncompressed' }; + const legacyWalletUncompressed = new LegacyWallet('Legacy (P2PKH) - Uncompressed'); + legacyWalletUncompressed.setSecret(privateKey); + + if (await wasUsed(legacyWalletUncompressed)) { + await fetch(legacyWalletUncompressed, true); + walletFound = true; + yield { wallet: legacyWalletUncompressed }; + } + + // compressed is true for other wallet types + privateKey = wif.encode(0x80, rawKeyBuffer, true); + + yield { progress: 'p2wpkh' }; + const segwitBech32Wallet = new SegwitBech32Wallet(); + segwitBech32Wallet.setSecret(privateKey); + + if (await wasUsed(segwitBech32Wallet)) { + await fetch(segwitBech32Wallet, true); + walletFound = true; + yield { wallet: segwitBech32Wallet }; + } + + yield { progress: 'p2tr' }; + const taprootWallet = new TaprootWallet(); + + taprootWallet.setSecret(privateKey); + if (await wasUsed(taprootWallet)) { + await fetch(taprootWallet, true); + walletFound = true; + yield { wallet: taprootWallet }; + } + + yield { progress: 'p2wpkh-p2sh' }; + + segwitWallet.setSecret(privateKey); + if (await wasUsed(segwitWallet)) { + await fetch(segwitWallet, true); + walletFound = true; + yield { wallet: segwitWallet }; + } + + yield { progress: 'p2pkh compressed' }; + const legacyWalletCompressed = new LegacyWallet('Legacy (P2PKH) - Compressed'); + legacyWalletCompressed.setSecret(privateKey); + + if (await wasUsed(legacyWalletCompressed)) { + await fetch(legacyWalletCompressed, true); + walletFound = true; + yield { wallet: legacyWalletCompressed }; + } + + if (!walletFound) { + yield { wallet: segwitBech32Wallet }; + yield { wallet: segwitWallet }; + yield { wallet: legacyWalletCompressed }; + yield { wallet: taprootWallet }; + yield { wallet: legacyWalletUncompressed }; + } + } + + // maybe its a watch-only address? + yield { progress: 'watch only' }; + const wo1 = new WatchOnlyWallet(); + wo1.setSecret(text); + if (wo1.valid()) { + wo1.init(); + if (text.startsWith('xpub')) { + // for xpub we also check ypub and zpub. If any of them was used, we import it. + let found = false; + const pubs = [text, wo1._xpubToYpub(text), wo1._xpubToZpub(text)]; + for (const pub of pubs) { + const wo2 = new WatchOnlyWallet(); + wo2.setSecret(pub); + wo2.init(); + if (await wasUsed(wo2)) { + yield { wallet: wo2 }; + found = true; + } + } + if (!found) { + await fetch(wo1, true); + yield { wallet: wo1 }; + } + } else { + await fetch(wo1, true); + yield { wallet: wo1 }; + } + } + + // electrum p2wpkh-p2sh + yield { progress: 'electrum p2wpkh-p2sh' }; + const el1 = new HDSegwitElectrumSeedP2WPKHWallet(); + el1.setSecret(text); + if (password) { + el1.setPassphrase(password); + } + if (el1.validateMnemonic()) { + yield { wallet: el1 }; // not fetching txs or balances, fuck it, yolo, life is too short + } + + // electrum p2wpkh-p2sh + yield { progress: 'electrum p2pkh' }; + const el2 = new HDLegacyElectrumSeedP2PKHWallet(); + el2.setSecret(text); + if (password) { + el2.setPassphrase(password); + } + if (el2.validateMnemonic()) { + yield { wallet: el2 }; // not fetching txs or balances, fuck it, yolo, life is too short + } + + // is it AEZEED? + yield { progress: 'aezeed' }; + const aezeed2 = new HDAezeedWallet(); + aezeed2.setSecret(text); + if (password) { + aezeed2.setPassphrase(password); + } + if (await aezeed2.validateMnemonicAsync()) { + yield { wallet: aezeed2 }; // not fetching txs or balances, fuck it, yolo, life is too short + } + + // Let's try SLIP39 + yield { progress: 'SLIP39' }; + const s1 = new SLIP39SegwitP2SHWallet(); + s1.setSecret(text); + + if (s1.validateMnemonic()) { + yield { progress: 'SLIP39 p2wpkh-p2sh' }; + if (password) { + s1.setPassphrase(password); + } + if (await wasUsed(s1)) { + yield { wallet: s1 }; + } + + yield { progress: 'SLIP39 p2pkh' }; + const s2 = new SLIP39LegacyP2PKHWallet(); + if (password) { + s2.setPassphrase(password); + } + s2.setSecret(text); + if (await wasUsed(s2)) { + yield { wallet: s2 }; + } + + yield { progress: 'SLIP39 p2wpkh' }; + const s3 = new SLIP39SegwitBech32Wallet(); + s3.setSecret(text); + if (password) { + s3.setPassphrase(password); + } + yield { wallet: s3 }; + } + + // is it BC-UR payload with multiple accounts? + yield { progress: 'BC-UR' }; + try { + const json = JSON.parse(text); + if (Array.isArray(json)) { + for (const account of json) { + if (account.ExtPubKey && account.MasterFingerprint && account.AccountKeyPath) { + const wallet = new WatchOnlyWallet(); + wallet.setSecret(JSON.stringify(account)); + wallet.init(); + yield { wallet }; + } + } + } + } catch (_) {} + + // is it a generic JSON with multiple accounts? + yield { progress: 'multi-account generic JSON' }; + try { + const json = JSON.parse(text); + + if (json.chain === 'BTC' && json.xfp) { + for (const account of ['bip86', 'bip84', 'bip49', 'bip44']) { + if (json[account] && json[account].desc) { + const wallet = new WatchOnlyWallet(); + wallet.setSecret(json[account].desc); + wallet.init(); + yield { wallet }; + } + } + } + } catch (_) {} + } + + // POEHALI + (async () => { + const generator = importGenerator(); + while (true) { + const next = await generator.next(); + if (!running) throw new Error('Discovery stopped'); // break if stop() has been called + if (next.value?.progress) reportProgress(next.value.progress); + if (next.value?.wallet) reportWallet(next.value.wallet); + if (next.done) break; // break if generator has been finished + await new Promise(resolve => setTimeout(resolve, 1)); // try not to block the thread + } + reportFinish(); + })().catch(e => { + if (e.message === 'Cancel Pressed') { + reportFinish(true); + return; + } else if (e.message === 'Discovery stopped') { + reportFinish(undefined, true); + return; + } + promiseReject(e); + }); + + return { promise, stop }; +}; + +export default startImport; diff --git a/class/wallets/abstract-hd-electrum-wallet.ts b/class/wallets/abstract-hd-electrum-wallet.ts index 463401dcc04..1bcabcc7188 100644 --- a/class/wallets/abstract-hd-electrum-wallet.ts +++ b/class/wallets/abstract-hd-electrum-wallet.ts @@ -1,27 +1,25 @@ /* eslint react/prop-types: "off", @typescript-eslint/ban-ts-comment: "off", camelcase: "off" */ -import * as bip39 from 'bip39'; +import BIP47Factory, { BIP47Interface } from '@spsina/bip47'; +import assert from 'assert'; import BigNumber from 'bignumber.js'; -import b58 from 'bs58check'; import BIP32Factory, { BIP32Interface } from 'bip32'; - -import { ECPairInterface } from 'ecpair/src/ecpair'; +import * as bip39 from 'bip39'; +import * as bitcoin from 'bitcoinjs-lib'; import { Psbt, Transaction as BTransaction } from 'bitcoinjs-lib'; -import { CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; -import ecc from '../../blue_modules/noble_ecc'; - -import BIP47Factory, { BIP47Interface } from '@spsina/bip47'; -import { ECPairFactory } from 'ecpair'; +import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; +import { ECPairFactory, ECPairInterface } from 'ecpair'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; +import ecc from '../../blue_modules/noble_ecc'; +import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras'; import { randomBytes } from '../rng'; import { AbstractHDWallet } from './abstract-hd-wallet'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; -import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; -import type BlueElectrumNs from '../../blue_modules/BlueElectrum'; +import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types'; +import { SilentPayment, UTXOType as SPUTXOType, UTXO as SPUTXO } from 'silent-payments'; +import { isValidBech32Address } from '../../util/isValidBech32Address.ts'; const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); -const BlueElectrum: typeof BlueElectrumNs = require('../../blue_modules/BlueElectrum'); -const reverse = require('buffer-reverse'); const bip32 = BIP32Factory(ecc); const bip47 = BIP47Factory(ecc); @@ -34,28 +32,65 @@ type BalanceByIndex = { * Electrum - means that it utilizes Electrum protocol for blockchain data */ export class AbstractHDElectrumWallet extends AbstractHDWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; static defaultRBFSequence = 2147483648; // 1 << 31, minimum for replaceable transactions as per BIP68 static finalRBFSequence = 4294967295; // 0xFFFFFFFF + // @ts-ignore: override + public readonly type = AbstractHDElectrumWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractHDElectrumWallet.typeReadable; _balances_by_external_index: Record; _balances_by_internal_index: Record; - // @ts-ignore _txs_by_external_index: Record; - // @ts-ignore _txs_by_internal_index: Record; _utxo: any[]; + _fp: string; // BIP47 _enable_BIP47: boolean; _payment_code: string; - _sender_payment_codes: string[]; - _addresses_by_payment_code: Record; - _next_free_payment_code_address_index: Record; + + /** + * payment codes of people who can pay us + */ + _receive_payment_codes: string[]; + + /** + * payment codes of people whom we can pay + */ + _send_payment_codes: string[]; + + /** + * joint addresses with remote counterparties, to receive funds + */ + _addresses_by_payment_code_receive: Record; + + /** + * receive index + */ + _next_free_payment_code_address_index_receive: Record; + + /** + * joint addresses with remote counterparties, whom we can send funds + */ + _addresses_by_payment_code_send: Record; + + /** + * send index + */ + _next_free_payment_code_address_index_send: Record; + + /** + * this is where we put transactions related to our PC receive addresses. this is both + * incoming transactions AND outgoing transactions (when we spend those funds) + * + */ _txs_by_payment_code_index: Record; + _balances_by_payment_code_index: Record; _bip47_instance?: BIP47Interface; @@ -72,11 +107,17 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // BIP47 this._enable_BIP47 = false; this._payment_code = ''; - this._sender_payment_codes = []; - this._next_free_payment_code_address_index = {}; + this._receive_payment_codes = []; + this._send_payment_codes = []; + this._next_free_payment_code_address_index_receive = {}; this._txs_by_payment_code_index = {}; + this._addresses_by_payment_code_send = {}; + this._next_free_payment_code_address_index_send = {}; this._balances_by_payment_code_index = {}; - this._addresses_by_payment_code = {}; + this._addresses_by_payment_code_receive = {}; + + // cache + this._fp = ''; } /** @@ -90,10 +131,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.c; } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { ret += this._getBalancesByPaymentCodeIndex(pc).c; } - return ret + (this.getUnconfirmedBalance() < 0 ? this.getUnconfirmedBalance() : 0); + const unconfirmed = this.getUnconfirmedBalance(); + return ret + (unconfirmed < 0 ? unconfirmed : 0); } /** @@ -108,21 +150,31 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const bal of Object.values(this._balances_by_internal_index)) { ret += bal.u; } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { ret += this._getBalancesByPaymentCodeIndex(pc).u; } return ret; } + getBalanceForExternalIndex(index: number): number { + const bal = this._balances_by_external_index[index]; + return (bal?.c || 0) + (bal?.u || 0); + } + + getTransactionCountForExternalIndex(index: number): number { + return this._txs_by_external_index[index]?.length ?? 0; + } + async generate() { const buf = await randomBytes(16); - this.secret = bip39.entropyToMnemonic(buf.toString('hex')); + this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(buf)); } - async generateFromEntropy(user: Buffer) { - const random = await randomBytes(user.length < 32 ? 32 - user.length : 0); - const buf = Buffer.concat([user, random], 32); - this.secret = bip39.entropyToMnemonic(buf.toString('hex')); + async generateFromEntropy(user: Uint8Array) { + if (user.length !== 32 && user.length !== 16) { + throw new Error('Entropy has to be 16 or 32 bytes long'); + } + this.secret = bip39.entropyToMnemonic(uint8ArrayToHex(user)); } _getExternalWIFByIndex(index: number): string | false { @@ -150,70 +202,37 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return child.toWIF(); } - _getNodeAddressByIndex(node: number, index: number): string { - index = index * 1; // cast to int - if (node === 0) { - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - } - - if (node === 1) { - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - } - - if (node === 0 && !this._node0) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(node); + _getNodeByIndex(node: 0 | 1, index: number): BIP32Interface { + const cachedNode = node === 0 ? this._node0 : this._node1; + if (cachedNode) { + return cachedNode.derive(index); } - if (node === 1 && !this._node1) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(node); - } + const xpub = this._zpubToXpub(this.getXpub()); + const hdNode = bip32.fromBase58(xpub).derive(node); - let address: string; if (node === 0) { - // @ts-ignore - address = this._hdNodeToAddress(this._node0.derive(index)); + this._node0 = hdNode; } else { - // tbh the only possible else is node === 1 - // @ts-ignore - address = this._hdNodeToAddress(this._node1.derive(index)); + this._node1 = hdNode; } - if (node === 0) { - return (this.external_addresses_cache[index] = address); - } else { - // tbh the only possible else option is node === 1 - return (this.internal_addresses_cache[index] = address); - } + return hdNode.derive(index); } - _getNodePubkeyByIndex(node: number, index: number) { - index = index * 1; // cast to int + _getNodeAddressByIndex(node: 0 | 1, index: number): string { + const cache = node === 0 ? this.external_addresses_cache : this.internal_addresses_cache; - if (node === 0 && !this._node0) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(node); - } + if (cache[index]) return cache[index]; // cache hit - if (node === 1 && !this._node1) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(node); - } - - if (node === 0 && this._node0) { - return this._node0.derive(index).publicKey; - } + const hdNode = this._getNodeByIndex(node, index); + const address = this._hdNodeToAddress(hdNode); - if (node === 1 && this._node1) { - return this._node1.derive(index).publicKey; - } + return (cache[index] = address); + } - throw new Error('Internal error: this._node0 or this._node1 is undefined'); + _getNodePubkeyByIndex(node: 0 | 1, index: number) { + return this._getNodeByIndex(node, index).publicKey; } _getExternalAddressByIndex(index: number): string { @@ -246,10 +265,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const xpub = child.toBase58(); // bitcoinjs does not support zpub yet, so we just convert it from xpub - let data = b58.decode(xpub); - data = data.slice(4); - data = Buffer.concat([Buffer.from('04b24746', 'hex'), data]); - this._xpub = b58.encode(data); + this._xpub = this._xpubToZpub(xpub); return this._xpub; } @@ -270,8 +286,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // then we combine it all together const addresses2fetch = []; + // Store these values to avoid a race condition if fetchBalance func changes them + const next_free_address_index = this.next_free_address_index; + const next_free_change_address_index = this.next_free_change_address_index; - for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { + for (let c = 0; c < next_free_address_index + this.gap_limit; c++) { // external addresses first let hasUnconfirmed = false; this._txs_by_external_index[c] = this._txs_by_external_index[c] || []; @@ -282,7 +301,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { + for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) { // next, internal addresses let hasUnconfirmed = false; this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || []; @@ -294,8 +313,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } // next, bip47 addresses - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { let hasUnconfirmed = false; this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; @@ -303,7 +322,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { hasUnconfirmed = hasUnconfirmed || !tx.confirmations || tx.confirmations < 7; if (hasUnconfirmed || this._txs_by_payment_code_index[pc][c].length === 0 || this._balances_by_payment_code_index[pc].u !== 0) { - addresses2fetch.push(this._getBIP47Address(pc, c)); + addresses2fetch.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -318,17 +337,21 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } // next, batch fetching each txid we got - const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs)); + const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs), true); // now, tricky part. we collect all transactions from inputs (vin), and batch fetch them too. // then we combine all this data (we need inputs to see source addresses and amounts) const vinTxids = []; for (const txdata of Object.values(txdatas)) { + if (txdata.vin.length > 99) continue; + // ^^^ cutoff, some transactions have thousands of inputs, so the resulting array of txs for inputs to fetch + // might be dozens of thousands. too much to handle, so we skip such transactions for (const vin of txdata.vin) { - vinTxids.push(vin.txid); + vin.txid && vinTxids.push(vin.txid); + // ^^^^ not all inputs have txid, some of them are Coinbase (newly-created coins) } } - const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids); + const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids, true); // fetched all transactions from our inputs. now we need to combine it. // iterating all _our_ transactions: @@ -348,144 +371,110 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { // now purge all unconfirmed txs from internal hashmaps, since some may be evicted from mempool because they became invalid // or replaced. hashmaps are going to be re-populated anyways, since we fetched TXs for addresses with unconfirmed TXs - for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { + for (let c = 0; c < next_free_address_index + this.gap_limit; c++) { this._txs_by_external_index[c] = this._txs_by_external_index[c].filter(tx => !!tx.confirmations); } - for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { + for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) { this._txs_by_internal_index[c] = this._txs_by_internal_index[c].filter(tx => !!tx.confirmations); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c].filter(tx => !!tx.confirmations); } } - // now, we need to put transactions in all relevant `cells` of internal hashmaps: this._txs_by_internal_index && this._txs_by_external_index + // now, we need to put transactions in all relevant `cells` of internal hashmaps: + // this._txs_by_internal_index, this._txs_by_external_index & this._txs_by_payment_code_index - for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { - for (const tx of Object.values(txdatas)) { - for (const vin of tx.vin) { - if (vin.addresses && vin.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) { - // this TX is related to our address - this._txs_by_external_index[c] = this._txs_by_external_index[c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) { - if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_external_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_external_index[c].push(clonedTx); - } - } - for (const vout of tx.vout) { - if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getExternalAddressByIndex(c)) !== -1) { - // this TX is related to our address - this._txs_by_external_index[c] = this._txs_by_external_index[c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_external_index[c].length; cc++) { - if (this._txs_by_external_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_external_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_external_index[c].push(clonedTx); - } - } + // address -> index lookup maps; the single pass over transactions below uses them + // to find which cells a transaction belongs to + const externalIndexByAddress = new Map(); + for (let c = 0; c < next_free_address_index + this.gap_limit; c++) { + externalIndexByAddress.set(this._getExternalAddressByIndex(c), c); + } + const internalIndexByAddress = new Map(); + for (let c = 0; c < next_free_change_address_index + this.gap_limit; c++) { + internalIndexByAddress.set(this._getInternalAddressByIndex(c), c); + } + const paymentCodeIndexByAddress = new Map(); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + paymentCodeIndexByAddress.set(this._getBIP47AddressReceive(pc, c), { pc, c }); } } - for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { - for (const tx of Object.values(txdatas)) { - for (const vin of tx.vin) { - if (vin.addresses && vin.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) { - // this TX is related to our address - this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) { - if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_internal_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_internal_index[c].push(clonedTx); - } - } - for (const vout of tx.vout) { - if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getInternalAddressByIndex(c)) !== -1) { - // this TX is related to our address - this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) { - if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_internal_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_internal_index[c].push(clonedTx); - } + // per-cell txid -> position lookup, used to replace-or-push a transaction into a cell in constant time + const cellPositionsByTxid = new Map>(); + const getCellPositions = (cell: Transaction[]): Map => { + let positions = cellPositionsByTxid.get(cell); + if (!positions) { + positions = new Map(); + for (let cc = 0; cc < cell.length; cc++) positions.set(cell[cc].txid, cc); + cellPositionsByTxid.set(cell, positions); + } + return positions; + }; + + for (const tx of Object.values(txdatas)) { + // collecting which of our address `cells` this transaction touches: + const externalCells = new Set(); + const internalCells = new Set(); + const paymentCodeCells = new Map(); + + const matchAddress = (address: string, isVout: boolean) => { + const externalIndex = externalIndexByAddress.get(address); + if (externalIndex !== undefined) externalCells.add(externalIndex); + const internalIndex = internalIndexByAddress.get(address); + if (internalIndex !== undefined) internalCells.add(internalIndex); + if (isVout) { + // since we are iterating PCs who can pay us, we can completely ignore `tx.vin` and only check `tx.vout` + const paymentCodeIndex = paymentCodeIndexByAddress.get(address); + if (paymentCodeIndex) paymentCodeCells.set(address, paymentCodeIndex); } + }; + + for (const vin of tx.vin) { + for (const address of vin.addresses ?? []) matchAddress(address, false); + } + for (const vout of tx.vout) { + for (const address of vout.scriptPubKey.addresses ?? []) matchAddress(address, true); } - } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - for (const tx of Object.values(txdatas)) { - for (const vin of tx.vin) { - if (vin.addresses && vin.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { - // this TX is related to our address - this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; - this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_payment_code_index[pc][c].length; cc++) { - if (this._txs_by_payment_code_index[pc][c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_payment_code_index[pc][c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_payment_code_index[pc][c].push(clonedTx); - } - } - for (const vout of tx.vout) { - if (vout.scriptPubKey.addresses && vout.scriptPubKey.addresses.indexOf(this._getBIP47Address(pc, c)) !== -1) { - // this TX is related to our address - this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; - this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; - const { vin: txVin, vout: txVout, ...txRest } = tx; - const clonedTx = { ...txRest, inputs: txVin.slice(0), outputs: txVout.slice(0) }; - - // trying to replace tx if it exists already (because it has lower confirmations, for example) - let replaced = false; - for (let cc = 0; cc < this._txs_by_internal_index[c].length; cc++) { - if (this._txs_by_internal_index[c][cc].txid === clonedTx.txid) { - replaced = true; - this._txs_by_internal_index[c][cc] = clonedTx; - } - } - if (!replaced) this._txs_by_internal_index[c].push(clonedTx); - } - } + if (externalCells.size === 0 && internalCells.size === 0 && paymentCodeCells.size === 0) continue; + + // this TX is related to our address(es) + const upsertClone = (cell: Transaction[]) => { + const { vin: txVin, vout: txVout, ...txRest } = tx; + const clonedTx = { + ...txRest, + inputs: txVin.slice(0), + outputs: txVout.slice(0), + timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */, + }; + + // trying to replace tx if it exists already (because it has lower confirmations, for example) + const positions = getCellPositions(cell); + const existingPosition = positions.get(clonedTx.txid); + if (existingPosition !== undefined) { + cell[existingPosition] = clonedTx; + } else { + positions.set(clonedTx.txid, cell.length); + cell.push(clonedTx); } + }; + + for (const c of externalCells) { + this._txs_by_external_index[c] = this._txs_by_external_index[c] || []; + upsertClone(this._txs_by_external_index[c]); + } + for (const c of internalCells) { + this._txs_by_internal_index[c] = this._txs_by_internal_index[c] || []; + upsertClone(this._txs_by_internal_index[c]); + } + for (const { pc, c } of paymentCodeCells.values()) { + this._txs_by_payment_code_index[pc] = this._txs_by_payment_code_index[pc] || {}; + this._txs_by_payment_code_index[pc][c] = this._txs_by_payment_code_index[pc][c] || []; + upsertClone(this._txs_by_payment_code_index[pc][c]); } } @@ -501,8 +490,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (const addressTxs of Object.values(this._txs_by_internal_index)) { txs = txs.concat(addressTxs); } - if (this._sender_payment_codes) { - for (const pc of this._sender_payment_codes) { + if (this._receive_payment_codes) { + for (const pc of this._receive_payment_codes) { if (this._txs_by_payment_code_index[pc]) for (const addressTxs of Object.values(this._txs_by_payment_code_index[pc])) { txs = txs.concat(addressTxs); @@ -521,10 +510,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } - if (this._sender_payment_codes) - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { - ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + if (this._receive_payment_codes) + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47AddressReceive(pc, c)] = true; } } // hack: in case this code is called from LegacyWallet: @@ -532,8 +521,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const ret: Transaction[] = []; for (const tx of txs) { - tx.received = tx.blocktime * 1000; - if (!tx.blocktime) tx.received = +new Date() - 30 * 1000; // unconfirmed + tx.timestamp = tx.blocktime || Math.floor(+new Date() / 1000) - 30; // fallback for unconfirmed tx.confirmations = tx.confirmations || 0; // unconfirmed tx.hash = tx.txid; tx.value = 0; @@ -551,6 +539,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { tx.value += new BigNumber(vout.value).multipliedBy(100000000).toNumber(); } } + + if (this.allowBIP47() && this.isBIP47Enabled()) { + tx.counterparty = this.getBip47CounterpartyByTx(tx); + } ret.push(tx); } @@ -563,15 +555,15 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } return ret2.sort(function (a, b) { - return Number(b.received) - Number(a.received); + return Number(b.timestamp) - Number(a.timestamp); }); } - async _binarySearchIterationForInternalAddress(index: number) { - const gerenateChunkAddresses = (chunkNum: number) => { + async _binarySearchLastUsedIndex(index: number, getAddressByIndex: (c: number) => string): Promise { + const generateChunkAddresses = (chunkNum: number) => { const ret = []; for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { - ret.push(this._getInternalAddressByIndex(c)); + ret.push(getAddressByIndex(c)); } return ret; }; @@ -579,9 +571,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { let lastChunkWithUsedAddressesNum = null; let lastHistoriesWithUsedAddresses = null; for (let c = 0; c < Math.round(index / this.gap_limit); c++) { - const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c)); - // @ts-ignore - if (this.constructor._getTransactionsFromHistories(histories).length > 0) { + const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c)); + if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) { // in this particular chunk we have used addresses lastChunkWithUsedAddressesNum = c; lastHistoriesWithUsedAddresses = histories; @@ -600,7 +591,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { c < Number(lastChunkWithUsedAddressesNum) * this.gap_limit + this.gap_limit; c++ ) { - const address = this._getInternalAddressByIndex(c); + const address = getAddressByIndex(c); if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused } @@ -610,102 +601,34 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return lastUsedIndex; } - async _binarySearchIterationForExternalAddress(index: number) { - const gerenateChunkAddresses = (chunkNum: number) => { - const ret = []; - for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { - ret.push(this._getExternalAddressByIndex(c)); - } - return ret; - }; - - let lastChunkWithUsedAddressesNum = null; - let lastHistoriesWithUsedAddresses = null; - for (let c = 0; c < Math.round(index / this.gap_limit); c++) { - const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c)); - // @ts-ignore - if (this.constructor._getTransactionsFromHistories(histories).length > 0) { - // in this particular chunk we have used addresses - lastChunkWithUsedAddressesNum = c; - lastHistoriesWithUsedAddresses = histories; - } else { - // empty chunk. no sense searching more chunks - break; - } - } - - let lastUsedIndex = 0; - - if (lastHistoriesWithUsedAddresses) { - // now searching for last used address in batch lastChunkWithUsedAddressesNum - for ( - let c = Number(lastChunkWithUsedAddressesNum) * this.gap_limit; - c < Number(lastChunkWithUsedAddressesNum) * this.gap_limit + this.gap_limit; - c++ - ) { - const address = this._getExternalAddressByIndex(c); - if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { - lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused - } - } - } + async _binarySearchIterationForInternalAddress(index: number) { + return this._binarySearchLastUsedIndex(index, c => this._getInternalAddressByIndex(c)); + } - return lastUsedIndex; + async _binarySearchIterationForExternalAddress(index: number) { + return this._binarySearchLastUsedIndex(index, c => this._getExternalAddressByIndex(c)); } async _binarySearchIterationForBIP47Address(paymentCode: string, index: number) { - const generateChunkAddresses = (chunkNum: number) => { - const ret = []; - for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { - ret.push(this._getBIP47Address(paymentCode, c)); - } - return ret; - }; - - let lastChunkWithUsedAddressesNum = null; - let lastHistoriesWithUsedAddresses = null; - for (let c = 0; c < Math.round(index / this.gap_limit); c++) { - const histories = await BlueElectrum.multiGetHistoryByAddress(generateChunkAddresses(c)); - // @ts-ignore - if (this.constructor._getTransactionsFromHistories(histories).length > 0) { - // in this particular chunk we have used addresses - lastChunkWithUsedAddressesNum = c; - lastHistoriesWithUsedAddresses = histories; - } else { - // empty chunk. no sense searching more chunks - break; - } - } - - let lastUsedIndex = 0; - - if (lastHistoriesWithUsedAddresses) { - // now searching for last used address in batch lastChunkWithUsedAddressesNum - for ( - let c = Number(lastChunkWithUsedAddressesNum) * this.gap_limit; - c < Number(lastChunkWithUsedAddressesNum) * this.gap_limit + this.gap_limit; - c++ - ) { - const address = this._getBIP47Address(paymentCode, c); - if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { - lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unused - } - } - } - - return lastUsedIndex; + return this._binarySearchLastUsedIndex(index, c => this._getBIP47AddressReceive(paymentCode, c)); } async fetchBalance() { try { if (this.next_free_change_address_index === 0 && this.next_free_address_index === 0) { - // doing binary search for last used address: - this.next_free_change_address_index = await this._binarySearchIterationForInternalAddress(1000); - this.next_free_address_index = await this._binarySearchIterationForExternalAddress(1000); - if (this._sender_payment_codes) { - for (const pc of this._sender_payment_codes) { - this._next_free_payment_code_address_index[pc] = await this._binarySearchIterationForBIP47Address(pc, 1000); - } + // doing binary search for last used address; chains are independent so we scan them in parallel: + const [nextFreeChange, nextFreeExternal] = await Promise.all([ + this._binarySearchIterationForInternalAddress(1000), + this._binarySearchIterationForExternalAddress(1000), + ]); + this.next_free_change_address_index = nextFreeChange; + this.next_free_address_index = nextFreeExternal; + if (this._receive_payment_codes) { + await Promise.all( + this._receive_payment_codes.map(async pc => { + this._next_free_payment_code_address_index_receive[pc] = await this._binarySearchIterationForBIP47Address(pc, 1000); + }), + ); } } // end rescanning fresh wallet @@ -728,13 +651,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = this.next_free_change_address_index; c < this.next_free_change_address_index + this.gap_limit; c++) { lagAddressesToFetch.push(this._getInternalAddressByIndex(c)); } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { for ( - let c = this._next_free_payment_code_address_index[pc]; - c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + let c = this._next_free_payment_code_address_index_receive[pc]; + c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++ ) { - lagAddressesToFetch.push(this._getBIP47Address(pc, c)); + lagAddressesToFetch.push(this._getBIP47AddressReceive(pc, c)); } } @@ -756,16 +679,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { for ( - let c = this._next_free_payment_code_address_index[pc]; - c < this._next_free_payment_code_address_index[pc] + this.gap_limit; + let c = this._next_free_payment_code_address_index_receive[pc]; + c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++ ) { - const address = this._getBIP47Address(pc, c); + const address = this._getBIP47AddressReceive(pc, c); if (txs[address] && Array.isArray(txs[address]) && txs[address].length > 0) { // whoa, someone uses our wallet outside! better catch up - this._next_free_payment_code_address_index[pc] = c + 1; + this._next_free_payment_code_address_index_receive[pc] = c + 1; } } } @@ -788,9 +711,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { addresses2fetch.push(this._getInternalAddressByIndex(c)); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { - addresses2fetch.push(this._getBIP47Address(pc, c)); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { + addresses2fetch.push(this._getBIP47AddressReceive(pc, c)); } } @@ -838,11 +761,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { + for (const pc of this._receive_payment_codes) { let confirmed = 0; let unconfirmed = 0; - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - const addr = this._getBIP47Address(pc, c); + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + const addr = this._getBIP47AddressReceive(pc, c); if (balances.addresses[addr].confirmed || balances.addresses[addr].unconfirmed) { confirmed = confirmed + balances.addresses[addr].confirmed; unconfirmed = unconfirmed + balances.addresses[addr].unconfirmed; @@ -857,7 +780,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._lastBalanceFetch = +new Date(); } - async fetchUtxo() { + async fetchUtxo(): Promise { // fetching utxo of addresses that only have some balance let addressess = []; @@ -873,10 +796,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { if (this._balances_by_payment_code_index?.[pc]?.c > 0) { - addressess.push(this._getBIP47Address(pc, c)); + addressess.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -893,10 +816,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._next_free_payment_code_address_index[pc] + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._next_free_payment_code_address_index_receive[pc] + this.gap_limit; c++) { if (this._balances_by_payment_code_index?.[pc]?.u > 0) { - addressess.push(this._getBIP47Address(pc, c)); + addressess.push(this._getBIP47AddressReceive(pc, c)); } } } @@ -913,17 +836,13 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { this._utxo = this._utxo.concat(arr); } - // backward compatibility TODO: remove when we make sure `.utxo` is not used - this.utxo = this._utxo; // this belongs in `.getUtxo()` - for (const u of this.utxo) { - u.txid = u.txId; - u.amount = u.value; + for (const u of this._utxo) { u.wif = this._getWifForAddress(u.address); if (!u.confirmations && u.height) u.confirmations = BlueElectrum.estimateCurrentBlockheight() - u.height; } - this.utxo = this.utxo.sort((a, b) => Number(a.amount) - Number(b.amount)); + this._utxo = this._utxo.sort((a, b) => Number(a.value) - Number(b.value)); // more consistent, so txhex in unit tests wont change } @@ -932,10 +851,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * [ { height: 0, * value: 666, * address: 'string', - * txId: 'string', * vout: 1, * txid: 'string', - * amount: 666, * wif: 'string', * confirmations: 0 } ] * @@ -968,13 +885,14 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + 1; c++) { ownedAddressesHashmap[this._getInternalAddressByIndex(c)] = true; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + 1; c++) { - ownedAddressesHashmap[this._getBIP47Address(pc, c)] = true; + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + 1; c++) { + ownedAddressesHashmap[this._getBIP47AddressReceive(pc, c)] = true; } } - for (const tx of this.getTransactions()) { + const txs = this.getTransactions(); + for (const tx of txs) { for (const output of tx.outputs) { let address: string | false = false; if (output.scriptPubKey && output.scriptPubKey.addresses && output.scriptPubKey.addresses[0]) { @@ -984,11 +902,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const value = new BigNumber(output.value).multipliedBy(100000000).toNumber(); utxos.push({ txid: tx.txid, - txId: tx.txid, vout: output.n, address: String(address), value, - amount: value, confirmations: tx.confirmations, wif: false, height: BlueElectrum.estimateCurrentBlockheight() - (tx.confirmations ?? 0), @@ -1000,17 +916,16 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (returnSpentUtxoAsWell) return utxos; // got all utxos we ever had. lets filter out the ones that are spent: - const ret = []; - for (const utxo of utxos) { - let spent = false; - for (const tx of this.getTransactions()) { - for (const input of tx.inputs) { - if (input.txid === utxo.txid && input.vout === utxo.vout) spent = true; - // utxo we got previously was actually spent right here ^^ - } + const spentOutpoints = new Set(); + for (const tx of txs) { + for (const input of tx.inputs) { + spentOutpoints.add(input.txid + ':' + input.vout); } + } - if (!spent) { + const ret = []; + for (const utxo of utxos) { + if (!spentOutpoints.has(utxo.txid + ':' + utxo.vout)) { // filling WIFs only for legit unspent UTXO, as it is a slow operation utxo.wif = this._getWifForAddress(utxo.address); ret.push(utxo); @@ -1028,10 +943,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return path + '/1/' + c; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { // not technically correct but well, to have at least somethign in PSBT... - if (this._getBIP47Address(pc, c) === address) return "m/47'/0'/0'/" + c; + if (this._getBIP47AddressReceive(pc, c) === address) return "m/47'/0'/0'/" + c; } } @@ -1041,18 +956,18 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { /** * * @param address {string} Address that belongs to this wallet - * @returns {Buffer|false} Either buffer with pubkey or false + * @returns {Uint8Array|false} Either Uint8Array with pubkey or false */ - _getPubkeyByAddress(address: string): Buffer | false { + _getPubkeyByAddress(address: string): Uint8Array | false { for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { if (this._getExternalAddressByIndex(c) === address) return this._getNodePubkeyByIndex(0, c); } for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getNodePubkeyByIndex(1, c); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return this._getBIP47PubkeyByIndex(pc, c); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return this._getBIP47PubkeyByIndex(pc, c); } } @@ -1072,9 +987,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === address) return this._getWIFByIndex(true, c); } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return this._getBIP47WIF(pc, c); + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return this._getBIP47WIF(pc, c); } } return false; @@ -1084,8 +999,10 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (!address) return false; let cleanAddress = address; - if (this.segwitType === 'p2wpkh') { - cleanAddress = address.toLowerCase(); + const isBech32Address = isValidBech32Address(address); + + if (isBech32Address) { + cleanAddress = address.toLocaleLowerCase(); } for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { @@ -1094,9 +1011,9 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { if (this._getInternalAddressByIndex(c) === cleanAddress) return true; } - for (const pc of this._sender_payment_codes) { - for (let c = 0; c < this._getNextFreePaymentCodeAddress(pc) + this.gap_limit; c++) { - if (this._getBIP47Address(pc, c) === address) return true; + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit; c++) { + if (this._getBIP47AddressReceive(pc, c) === address) return true; } } return false; @@ -1104,7 +1021,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String}>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String}>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -1115,39 +1032,61 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { */ createTransaction( utxos: CreateTransactionUtxo[], - targets: CoinSelectTarget[], + targets: CreateTransactionTarget[], feeRate: number, changeAddress: string, - sequence: number, + sequence: number = AbstractHDElectrumWallet.defaultRBFSequence, skipSigning = false, - masterFingerprint: number, + masterFingerprint: number = 0, ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); - // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation - for (const u of utxos) { - // this is a hacky way to distinguish native/wrapped segwit, but its good enough for our case since we have only - // those 2 wallet types - if (this._getExternalAddressByIndex(0).startsWith('bc1')) { - u.script = { length: 27 }; - } else if (this._getExternalAddressByIndex(0).startsWith('3')) { - u.script = { length: 50 }; + + let { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + + const hasSilentPaymentOutput: boolean = !!outputs.find(o => o.address?.startsWith('sp1')); + if (hasSilentPaymentOutput) { + if (!this.allowSilentPaymentSend()) { + throw new Error('This wallet can not send to SilentPayment address'); } - } - for (const t of targets) { - if (t.address.startsWith('bc1')) { - // in case address is non-typical and takes more bytes than coinselect library anticipates by default - t.script = { length: bitcoin.address.toOutputScript(t.address).length + 3 }; + // for a single wallet all utxos gona be the same type, so we define it only once: + let utxoType: SPUTXOType = 'non-eligible'; + switch (this.segwitType) { + case 'p2tr': + utxoType = 'p2tr'; + break; + case 'p2sh(p2wpkh)': + utxoType = 'p2sh-p2wpkh'; + break; + case 'p2wpkh': + utxoType = 'p2wpkh'; + break; + default: + // @ts-ignore override + if (this.type === 'HDlegacyP2PKH') utxoType = 'p2pkh'; } - } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const spUtxos: SPUTXO[] = inputs.map(u => ({ ...u, utxoType, wif: u.wif! })); + const sp = new SilentPayment(); + outputs = sp.createTransaction(spUtxos, outputs) as CoinSelectOutput[]; + } sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; let psbt = new bitcoin.Psbt(); let c = 0; const keypairs: Record = {}; - const values: Record = {}; + + // this is not correct fingerprint, as we dont know real fingerprint - we got zpub with 84/0, but fingerpting + // should be from root. basically, fingerprint should be provided from outside by user when importing zpub + let masterFingerprintBuffer: Uint8Array; + if (masterFingerprint) { + let masterFingerprintHex = Number(masterFingerprint).toString(16); + if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + const hexBuffer = hexToUint8Array(masterFingerprintHex); + masterFingerprintBuffer = hexBuffer.reverse(); + } else { + masterFingerprintBuffer = new Uint8Array([0x00, 0x00, 0x00, 0x00]); + } inputs.forEach(input => { let keyPair; @@ -1156,57 +1095,40 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { keyPair = ECPair.fromWIF(this._getWifForAddress(String(input.address))); keypairs[c] = keyPair; } - values[c] = input.value; c++; if (!skipSigning) { // skiping signing related stuff if (!input.address || !this._getWifForAddress(input.address)) throw new Error('Internal error: no address or WIF to sign input'); } - let masterFingerprintBuffer; - if (masterFingerprint) { - let masterFingerprintHex = Number(masterFingerprint).toString(16); - if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte - const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); - masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); - } else { - masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); - } - // this is not correct fingerprint, as we dont know real fingerprint - we got zpub with 84/0, but fingerpting - // should be from root. basically, fingerprint should be provided from outside by user when importing zpub - psbt = this._addPsbtInput(psbt, input, sequence, masterFingerprintBuffer); }); outputs.forEach(output => { - // if output has no address - this is change output + // if output has no address - this is change output or a custom script output let change = false; - if (!output.address) { + // @ts-ignore + if (!output.address && !output.script?.hex) { change = true; output.address = changeAddress; } const path = this._getDerivationPathByAddress(String(output.address)); const pubkey = this._getPubkeyByAddress(String(output.address)); - let masterFingerprintBuffer; - if (masterFingerprint) { - let masterFingerprintHex = Number(masterFingerprint).toString(16); - if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte - const hexBuffer = Buffer.from(masterFingerprintHex, 'hex'); - masterFingerprintBuffer = Buffer.from(reverse(hexBuffer)); - } else { - masterFingerprintBuffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); + if (output.address?.startsWith('PM')) { + // ok its BIP47 payment code, so we need to unwrap a joint address for the receiver and use it instead: + output.address = this._getNextFreePaymentCodeAddressSend(output.address); + // ^^^ trusting that notification transaction is in place } - // this is not correct fingerprint, as we dont know realfingerprint - we got zpub with 84/0, but fingerpting - // should be from root. basically, fingerprint should be provided from outside by user when importing zpub - psbt.addOutput({ address: output.address, - value: output.value, + // @ts-ignore types from bitcoinjs are not exported so we cant define outputData separately and add fields conditionally (either address or script should be present) + script: output.script?.hex ? hexToUint8Array(output.script.hex) : undefined, + value: BigInt(output.value), bip32Derivation: - change && path && pubkey + change && path && pubkey && this.segwitType !== 'p2tr' ? [ { masterFingerprint: masterFingerprintBuffer, @@ -1215,13 +1137,33 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { }, ] : [], + tapBip32Derivation: + this.segwitType === 'p2tr' && pubkey && path && change + ? [ + { + pubkey: new Uint8Array(pubkey), + masterFingerprint: new Uint8Array(masterFingerprintBuffer), + path, + leafHashes: [], + }, + ] + : [], + ...(this.segwitType === 'p2tr' && pubkey ? { tapInternalKey: new Uint8Array(pubkey) } : {}), }); }); if (!skipSigning) { // skiping signing related stuff for (let cc = 0; cc < c; cc++) { - psbt.signInput(cc, keypairs[cc]); + if (this.segwitType === 'p2tr') { + assert(psbt.data.inputs[cc].tapInternalKey, 'TapInternalKey is required for taproot inputs'); + psbt.signTaprootInput( + cc, + keypairs[cc].tweak(bitcoin.crypto.taggedHash('TapTweak', psbt.data.inputs[cc].tapInternalKey as Uint8Array)), + ); + } else { + psbt.signInput(cc, keypairs[cc]); + } } } @@ -1232,7 +1174,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return { tx, inputs, outputs, fee, psbt }; } - _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Buffer) { + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Uint8Array) { if (!input.address) { throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); } @@ -1242,10 +1184,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { throw new Error('Internal error: pubkey or path are invalid'); } const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + if (!p2wpkh.output) { + throw new Error('Internal error: could not create p2wpkh output during _addPsbtInput'); + } psbt.addInput({ - // @ts-ignore - hash: input.txid || input.txId, + hash: input.txid, index: input.vout, sequence, bip32Derivation: [ @@ -1257,7 +1201,7 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { ], witnessUtxo: { script: p2wpkh.output, - value: input.value, + value: BigInt(input.value), }, }); @@ -1292,15 +1236,27 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * Creates Segwit Bech32 Bitcoin address */ _nodeToBech32SegwitAddress(hdNode: BIP32Interface): string { - return bitcoin.payments.p2wpkh({ + const { address } = bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey, - }).address; + }); + + if (!address) { + throw new Error('Could not create address in _nodeToBech32SegwitAddress'); + } + + return address; } _nodeToLegacyAddress(hdNode: BIP32Interface): string { - return bitcoin.payments.p2pkh({ + const { address } = bitcoin.payments.p2pkh({ pubkey: hdNode.publicKey, - }).address; + }); + + if (!address) { + throw new Error('Could not create address in _nodeToLegacyAddress'); + } + + return address; } /** @@ -1310,6 +1266,11 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const { address } = bitcoin.payments.p2sh({ redeem: bitcoin.payments.p2wpkh({ pubkey: hdNode.publicKey }), }); + + if (!address) { + throw new Error('Could not create address in _nodeToP2shSegwitAddress'); + } + return address; } @@ -1339,6 +1300,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { if (!this.allowBIP47()) { return false; } + try { + // watch-only wallet will throw an error here + this.getDerivationPath(); + } catch (_) { + return false; + } // only check BIP47 if derivation path is regular, otherwise too many wallets will be found if (!["m/84'/0'/0'", "m/44'/0'/0'", "m/49'/0'/0'"].includes(this.getDerivationPath() as string)) { return false; @@ -1360,6 +1327,17 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { ret.push(this._getExternalAddressByIndex(c)); } + if (this.allowBIP47() && this.isBIP47Enabled()) { + // returning BIP47 joint addresses with everyone who can pay us because they are kinda our 'external' aka 'receive' addresses + + for (const pc of this._receive_payment_codes) { + for (let c = 0; c < this._getNextFreePaymentCodeIndexReceive(pc) + this.gap_limit / 4; c++) { + // ^^^ not full gap limit to reduce computation (theoretically, there should not be gaps at all) + ret.push(this._getBIP47AddressReceive(pc, c)); + } + } + } + return ret; } @@ -1426,12 +1404,12 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } /** - * @param seed {Buffer} Buffer object with seed - * @returns {string} Hex string of fingerprint derived from mnemonics. Always has lenght of 8 chars and correct leading zeroes. All caps + * @param seed {Uint8Array} Uint8Array with seed + * @returns {string} Hex string of fingerprint derived from mnemonics. Always has length of 8 chars and correct leading zeroes. All caps */ - static seedToFingerprint(seed: Buffer) { + static seedToFingerprint(seed: Uint8Array) { const root = bip32.fromSeed(seed); - let hex = root.fingerprint.toString('hex'); + let hex = uint8ArrayToHex(root.fingerprint); while (hex.length < 8) hex = '0' + hex; // leading zeroes return hex.toUpperCase(); } @@ -1440,17 +1418,22 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { * @param mnemonic {string} Mnemonic phrase (12 or 24 words) * @returns {string} Hex fingerprint */ - static mnemonicToFingerprint(mnemonic: string, passphrase: string) { + static mnemonicToFingerprint(mnemonic: string, passphrase?: string) { const seed = bip39.mnemonicToSeedSync(mnemonic, passphrase); return AbstractHDElectrumWallet.seedToFingerprint(seed); } /** - * @returns {string} Hex string of fingerprint derived from wallet mnemonics. Always has lenght of 8 chars and correct leading zeroes + * @returns Hex string of fingerprint derived from wallet mnemonics. Always has length of 8 chars and correct leading zeroes */ getMasterFingerprintHex() { + if (this._fp) { + return this._fp; // cache hit + } + const seed = this._getSeed(); - return AbstractHDElectrumWallet.seedToFingerprint(seed); + this._fp = AbstractHDElectrumWallet.seedToFingerprint(seed); + return this._fp; } /** @@ -1473,6 +1456,145 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return this._bip47_instance; } + /** + * find and return _existing_ notification transaction for the given payment code + * (i.e. if it exists - we notified in the past and dont need to notify again) + */ + getBIP47NotificationTransaction(receiverPaymentCode: string): Transaction | undefined { + const publicBip47 = BIP47Factory(ecc).fromPaymentCode(receiverPaymentCode); + const remoteNotificationAddress = publicBip47.getNotificationAddress(); + + for (const tx of this.getTransactions()) { + for (const output of tx.outputs) { + if (output.scriptPubKey?.addresses?.includes(remoteNotificationAddress)) return tx; + // ^^^ if in the past we sent a tx to his notification address - most likely that was a proper notification + // transaction with OP_RETURN. + // but not gona verify it here, will just trust it + } + } + } + + /** + * return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid) + * or undefined if it was a non-BIP47 transaction + */ + getBip47CounterpartyByTxid(txid: string): string | undefined { + const foundTx = this.getTransactions().find(tx => tx.txid === txid); + if (foundTx) { + return this.getBip47CounterpartyByTx(foundTx); + } + return undefined; + } + + /** + * return BIP47 payment code of the counterparty of this transaction (someone who paid us, or someone we paid) + * or undefined if it was a non-BIP47 transaction + */ + getBip47CounterpartyByTx(tx: Transaction): string | undefined { + for (const pc of Object.keys(this._txs_by_payment_code_index)) { + // iterating all payment codes + + for (const txs of Object.values(this._txs_by_payment_code_index[pc])) { + for (const tx2 of txs) { + if (tx2.txid === tx.txid) { + return pc; // found it! + } + } + } + } + + // checking txs we sent to counterparties + + for (const pc of this._send_payment_codes) { + for (const out of tx.outputs) { + for (const address of out.scriptPubKey?.addresses ?? []) { + if (this._addresses_by_payment_code_send[pc] && Object.values(this._addresses_by_payment_code_send[pc]).includes(address)) { + // found it! + return pc; + } + } + } + } + + return undefined; // found nothing + } + + createBip47NotificationTransaction(utxos: CreateTransactionUtxo[], receiverPaymentCode: string, feeRate: number, changeAddress: string) { + const aliceBip47 = BIP47Factory(ecc).fromBip39Seed(this.getSecret(), undefined, this.getPassphrase()); + const bobBip47 = BIP47Factory(ecc).fromPaymentCode(receiverPaymentCode); + assert(utxos[0], 'No UTXO'); + assert(utxos[0].wif, 'No UTXO WIF'); + + // constructing targets: notification address, _dummy_ payload (+potential change might be added later) + + const targetsTemp: CreateTransactionTarget[] = []; + targetsTemp.push({ + address: bobBip47.getNotificationAddress(), + value: 546, // minimum permissible utxo size + }); + targetsTemp.push({ + value: 0, + script: { + hex: uint8ArrayToHex(new Uint8Array(83)), // no `address` here, its gonabe op_return. but we pass dummy data here with a correct size just to choose utxo + }, + }); + + // creating temp transaction so that utxo can be selected: + + const { inputs: inputsTemp } = this.createTransaction( + utxos, + targetsTemp, + feeRate, + changeAddress, + AbstractHDElectrumWallet.defaultRBFSequence, + false, + 0, + ); + assert(inputsTemp?.[0]?.wif, 'inputsTemp?.[0]?.wif assert failed'); + + // utxo selected. lets create op_return payload using the correct (first!) utxo and correct targets with that payload + + const keyPair = ECPair.fromWIF(inputsTemp[0].wif); + const outputNumber = new Uint8Array(4); // 00000000 in hex + new DataView(outputNumber.buffer).setUint32(0, inputsTemp[0].vout, true); // little-endian + const blindedPaymentCode = aliceBip47.getBlindedPaymentCode( + bobBip47, + keyPair.privateKey as Buffer, + // txid is reversed, as well as output number + uint8ArrayToHex(hexToUint8Array(inputsTemp[0].txid).reverse()) + uint8ArrayToHex(outputNumber), + ); + + // targets: + + const targets: CreateTransactionTarget[] = []; + targets.push({ + address: bobBip47.getNotificationAddress(), + value: 546, // minimum permissible utxo size + }); + targets.push({ + value: 0, + script: { + hex: '6a4c50' + blindedPaymentCode, // no `address` here, only script (which is OP_RETURN + data payload) + }, + }); + + // finally a transaction: + + const { tx, outputs, inputs, fee, psbt } = this.createTransaction( + utxos, + targets, + feeRate, + changeAddress, + AbstractHDElectrumWallet.defaultRBFSequence, + false, + 0, + ); + assert(inputs && inputs[0] && inputs[0].wif, 'inputs && inputs[0] && inputs[0].wif assert failed'); + assert(inputs[0].txid === inputsTemp[0].txid, 'inputs[0].txid === inputsTemp[0].txid assert failed'); // making sure that no funky business happened under the hood (its supposed to stay the same) + + return { tx, inputs, outputs, fee, psbt }; + } + getBIP47PaymentCode(): string { if (!this._payment_code) { this._payment_code = this.getBIP47FromSeed().getSerializedPaymentCode(); @@ -1486,17 +1608,21 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { return bip47Local.getNotificationAddress(); } + /** + * check our notification address, and decypher all payment codes people notified us + * about (so they can pay us) + */ async fetchBIP47SenderPaymentCodes(): Promise { const bip47_instance = this.getBIP47FromSeed(); const address = bip47_instance.getNotificationAddress(); const histories = await BlueElectrum.multiGetHistoryByAddress([address]); const txHashes = histories[address].map(({ tx_hash }) => tx_hash); - const txHexs = await BlueElectrum.multiGetTransactionByTxid(txHashes, 50, false); + const txHexs = await BlueElectrum.multiGetTransactionByTxid(txHashes, false); for (const txHex of Object.values(txHexs)) { try { const paymentCode = bip47_instance.getPaymentCodeFromRawNotificationTransaction(txHex); - if (this._sender_payment_codes.includes(paymentCode)) continue; // already have it + if (this._receive_payment_codes.includes(paymentCode)) continue; // already have it // final check if PC is even valid (could've been constructed by a buggy code, and our code would crash with that): try { @@ -1505,8 +1631,8 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { continue; } - this._sender_payment_codes.push(paymentCode); - this._next_free_payment_code_address_index[paymentCode] = 0; // initialize + this._receive_payment_codes.push(paymentCode); + this._next_free_payment_code_address_index_receive[paymentCode] = 0; // initialize this._balances_by_payment_code_index[paymentCode] = { c: 0, u: 0 }; } catch (e) { // do nothing @@ -1514,19 +1640,66 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { } } + /** + * for counterparties we can pay, we sync shared addresses to find the one we havent used yet. + * this method could benefit from rewriting in batch requests, but not necessary - its only going to be called + * once in a while (when user decides to pay a given counterparty again) + */ + async syncBip47ReceiversAddresses(receiverPaymentCode: string) { + this._next_free_payment_code_address_index_send[receiverPaymentCode] = + this._next_free_payment_code_address_index_send[receiverPaymentCode] || 0; // init + + for (let c = this._next_free_payment_code_address_index_send[receiverPaymentCode]; c < 999999; c++) { + const address = this._getBIP47AddressSend(receiverPaymentCode, c); + + this._addresses_by_payment_code_send[receiverPaymentCode] = this._addresses_by_payment_code_send[receiverPaymentCode] || {}; // init + this._addresses_by_payment_code_send[receiverPaymentCode][c] = address; + const histories = await BlueElectrum.multiGetHistoryByAddress([address]); + if (histories?.[address]?.length > 0) { + // address is used; + continue; + } + + // empty address, stop here, we found our latest index and filled array with shared addresses + this._next_free_payment_code_address_index_send[receiverPaymentCode] = c; + break; + } + } + + /** + * payment codes of people who can pay us + */ getBIP47SenderPaymentCodes(): string[] { - return this._sender_payment_codes; + return this._receive_payment_codes; + } + + /** + * payment codes of people whom we can pay + */ + getBIP47ReceiverPaymentCodes(): string[] { + return this._send_payment_codes; + } + + /** + * adding counterparty whom we can pay. trusting that notificaton transaction is in place already + */ + addBIP47Receiver(paymentCode: string) { + if (this._send_payment_codes.includes(paymentCode)) return; // duplicates + this._send_payment_codes.push(paymentCode); } _hdNodeToAddress(hdNode: BIP32Interface): string { return this._nodeToBech32SegwitAddress(hdNode); } - _getBIP47Address(paymentCode: string, index: number): string { - if (!this._addresses_by_payment_code[paymentCode]) this._addresses_by_payment_code[paymentCode] = []; + /** + * returns joint addresses to receive coins with a given counterparty + */ + _getBIP47AddressReceive(paymentCode: string, index: number): string { + if (!this._addresses_by_payment_code_receive[paymentCode]) this._addresses_by_payment_code_receive[paymentCode] = []; - if (this._addresses_by_payment_code[paymentCode][index]) { - return this._addresses_by_payment_code[paymentCode][index]; + if (this._addresses_by_payment_code_receive[paymentCode][index]) { + return this._addresses_by_payment_code_receive[paymentCode][index]; } const bip47_instance = this.getBIP47FromSeed(); @@ -1535,12 +1708,38 @@ export class AbstractHDElectrumWallet extends AbstractHDWallet { const hdNode = bip47_instance.getPaymentWallet(remotePaymentNode, index); const address = this._hdNodeToAddress(hdNode); this._address_to_wif_cache[address] = hdNode.toWIF(); - this._addresses_by_payment_code[paymentCode][index] = address; + this._addresses_by_payment_code_receive[paymentCode][index] = address; return address; } - _getNextFreePaymentCodeAddress(paymentCode: string) { - return this._next_free_payment_code_address_index[paymentCode] || 0; + /** + * returns joint addresses to send coins to + */ + _getBIP47AddressSend(paymentCode: string, index: number): string { + if (!this._addresses_by_payment_code_send[paymentCode]) this._addresses_by_payment_code_send[paymentCode] = []; + + if (this._addresses_by_payment_code_send[paymentCode][index]) { + // cache hit + return this._addresses_by_payment_code_send[paymentCode][index]; + } + + const hdNode = this.getBIP47FromSeed().getReceiveWallet(BIP47Factory(ecc).fromPaymentCode(paymentCode).getPaymentCodeNode(), index); + const address = this._hdNodeToAddress(hdNode); + this._addresses_by_payment_code_send[paymentCode][index] = address; + return address; + } + + _getNextFreePaymentCodeIndexReceive(paymentCode: string) { + return this._next_free_payment_code_address_index_receive[paymentCode] || 0; + } + + /** + * when sending funds to a payee, this method will return next unused joint address for him. + * this method assumes that we synced our payee via `syncBip47ReceiversAddresses()` + */ + _getNextFreePaymentCodeAddressSend(paymentCode: string) { + this._next_free_payment_code_address_index_send[paymentCode] = this._next_free_payment_code_address_index_send[paymentCode] || 0; + return this._getBIP47AddressSend(paymentCode, this._next_free_payment_code_address_index_send[paymentCode]); } _getBalancesByPaymentCodeIndex(paymentCode: string): BalanceByIndex { diff --git a/class/wallets/abstract-hd-wallet.ts b/class/wallets/abstract-hd-wallet.ts index 50e528a6a0e..3553106a046 100644 --- a/class/wallets/abstract-hd-wallet.ts +++ b/class/wallets/abstract-hd-wallet.ts @@ -1,8 +1,9 @@ -import { LegacyWallet } from './legacy-wallet'; -import * as bip39 from 'bip39'; import { BIP32Interface } from 'bip32'; +import * as bip39 from 'bip39'; + import * as bip39custom from '../../blue_modules/bip39'; -import BlueElectrum from '../../blue_modules/BlueElectrum'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { LegacyWallet } from './legacy-wallet'; import { Transaction } from './types'; type AbstractHDWalletStatics = { @@ -13,15 +14,18 @@ type AbstractHDWalletStatics = { * @deprecated */ export class AbstractHDWallet extends LegacyWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; + // @ts-ignore: override + public readonly type = AbstractHDWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractHDWallet.typeReadable; next_free_address_index: number; next_free_change_address_index: number; internal_addresses_cache: Record; external_addresses_cache: Record; _xpub: string; - usedAddresses: string[]; _address_to_wif_cache: Record; gap_limit: number; passphrase?: string; @@ -36,7 +40,6 @@ export class AbstractHDWallet extends LegacyWallet { this.internal_addresses_cache = {}; // index => address this.external_addresses_cache = {}; // index => address this._xpub = ''; // cache - this.usedAddresses = []; this._address_to_wif_cache = {}; this.gap_limit = 20; this._derivationPath = Constructor.derivationPath; @@ -69,9 +72,9 @@ export class AbstractHDWallet extends LegacyWallet { } /** - * @return {Buffer} wallet seed + * @return {Uint8Array} wallet seed */ - _getSeed(): Buffer { + _getSeed(): Uint8Array { const mnemonic = this.secret; const passphrase = this.passphrase; return bip39.mnemonicToSeedSync(mnemonic, passphrase); @@ -143,33 +146,7 @@ export class AbstractHDWallet extends LegacyWallet { */ async getAddressAsync(): Promise { // looking for free external address - let freeAddress = ''; - let c; - for (c = 0; c < this.gap_limit + 1; c++) { - if (this.next_free_address_index + c < 0) continue; - const address = this._getExternalAddressByIndex(this.next_free_address_index + c); - this.external_addresses_cache[this.next_free_address_index + c] = address; // updating cache just for any case - let txs = []; - try { - txs = await BlueElectrum.getTransactionsByAddress(address); - } catch (Err: any) { - console.warn('BlueElectrum.getTransactionsByAddress()', Err.message); - } - if (txs.length === 0) { - // found free address - freeAddress = address; - this.next_free_address_index += c; // now points to _this one_ - break; - } - } - - if (!freeAddress) { - // could not find in cycle above, give up - freeAddress = this._getExternalAddressByIndex(this.next_free_address_index + c); // we didnt check this one, maybe its free - this.next_free_address_index += c; // now points to this one - } - this._address = freeAddress; - return freeAddress; + return this._getNextFreeAddress(false); } /** @@ -181,12 +158,21 @@ export class AbstractHDWallet extends LegacyWallet { */ async getChangeAddressAsync(): Promise { // looking for free internal address - let freeAddress = ''; + return this._getNextFreeAddress(true); + } + + async _getNextFreeAddress(internal: boolean): Promise { + const startIndex = internal ? this.next_free_change_address_index : this.next_free_address_index; + const cache = internal ? this.internal_addresses_cache : this.external_addresses_cache; + const getAddressByIndex = internal + ? (index: number) => this._getInternalAddressByIndex(index) + : (index: number) => this._getExternalAddressByIndex(index); + let c; for (c = 0; c < this.gap_limit + 1; c++) { - if (this.next_free_change_address_index + c < 0) continue; - const address = this._getInternalAddressByIndex(this.next_free_change_address_index + c); - this.internal_addresses_cache[this.next_free_change_address_index + c] = address; // updating cache just for any case + if (startIndex + c < 0) continue; + const address = getAddressByIndex(startIndex + c); + cache[startIndex + c] = address; // updating cache just for any case let txs = []; try { txs = await BlueElectrum.getTransactionsByAddress(address); @@ -195,16 +181,17 @@ export class AbstractHDWallet extends LegacyWallet { } if (txs.length === 0) { // found free address - freeAddress = address; - this.next_free_change_address_index += c; // now points to _this one_ break; } } - if (!freeAddress) { - // could not find in cycle above, give up - freeAddress = this._getInternalAddressByIndex(this.next_free_change_address_index + c); // we didnt check this one, maybe its free - this.next_free_change_address_index += c; // now points to this one + // if the loop found no free address we give up and take the first unchecked one — maybe its free + const freeAddress = getAddressByIndex(startIndex + c); + // now points to _this one_ + if (internal) { + this.next_free_change_address_index = startIndex + c; + } else { + this.next_free_address_index = startIndex + c; } this._address = freeAddress; return freeAddress; @@ -310,7 +297,7 @@ export class AbstractHDWallet extends LegacyWallet { throw new Error('Not implemented'); } - _getNodePubkeyByIndex(node: number, index: number): Buffer | undefined { + _getNodePubkeyByIndex(node: 0 | 1, index: number): Uint8Array | undefined { throw new Error('Not implemented'); } diff --git a/class/wallets/abstract-wallet.ts b/class/wallets/abstract-wallet.ts index f48a5bd4542..a63f8445ad6 100644 --- a/class/wallets/abstract-wallet.ts +++ b/class/wallets/abstract-wallet.ts @@ -1,14 +1,10 @@ -import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import b58 from 'bs58check'; -import createHash from 'create-hash'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; +import { sha256 } from '@noble/hashes/sha256'; +import wif from 'wif'; -type WalletStatics = { - type: string; - typeReadable: string; - segwitType?: 'p2wpkh' | 'p2sh(p2wpkh)'; - derivationPath?: string; -}; +import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; +import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; +import { hexToUint8Array, concatUint8Arrays, uint8ArrayToHex } from '../../blue_modules/uint8array-extras'; type WalletWithPassphrase = AbstractWallet & { getPassphrase: () => string }; type UtxoMetadata = { @@ -17,8 +13,12 @@ type UtxoMetadata = { }; export class AbstractWallet { - static type = 'abstract'; - static typeReadable = 'abstract'; + static readonly type = 'abstract'; + static readonly typeReadable = 'abstract'; + // @ts-ignore: override + public readonly type = AbstractWallet.type; + // @ts-ignore: override + public readonly typeReadable = AbstractWallet.typeReadable; static fromJson(obj: string): AbstractWallet { const obj2 = JSON.parse(obj); @@ -31,16 +31,14 @@ export class AbstractWallet { return temp; } - type: string; - typeReadable: string; - segwitType?: 'p2wpkh' | 'p2sh(p2wpkh)'; + segwitType?: 'p2wpkh' | 'p2sh(p2wpkh)' | 'p2tr' | 'p2pkh' /* not segwit but ok */; _derivationPath?: string; label: string; secret: string; balance: number; unconfirmed_balance: number; _address: string | false; - utxo: Utxo[]; + _utxo: Utxo[]; _lastTxFetch: number; _lastBalanceFetch: number; preferredBalanceUnit: BitcoinUnit; @@ -50,20 +48,15 @@ export class AbstractWallet { _hideTransactionsInWalletsList: boolean; _utxoMetadata: Record; use_with_hardware_wallet: boolean; - masterFingerprint: number | false; + masterFingerprint: number; constructor() { - const Constructor = this.constructor as unknown as WalletStatics; - - this.type = Constructor.type; - this.typeReadable = Constructor.typeReadable; - this.segwitType = Constructor.segwitType; this.label = ''; this.secret = ''; // private key or recovery phrase this.balance = 0; this.unconfirmed_balance = 0; this._address = false; // cache - this.utxo = []; + this._utxo = []; this._lastTxFetch = 0; this._lastBalanceFetch = 0; this.preferredBalanceUnit = BitcoinUnit.BTC; @@ -73,7 +66,7 @@ export class AbstractWallet { this._hideTransactionsInWalletsList = false; this._utxoMetadata = {}; this.use_with_hardware_wallet = false; - this.masterFingerprint = false; + this.masterFingerprint = 0; } /** @@ -88,7 +81,7 @@ export class AbstractWallet { const passphrase = thisWithPassphrase.getPassphrase ? thisWithPassphrase.getPassphrase() : ''; const path = this._derivationPath ?? ''; const string2hash = this.type + this.getSecret() + passphrase + path; - return createHash('sha256').update(string2hash).digest().toString('hex'); + return uint8ArrayToHex(sha256(string2hash)); } getTransactions(): Transaction[] { @@ -131,18 +124,25 @@ export class AbstractWallet { * @returns {number} Available to spend amount, int, in sats */ getBalance(): number { - return this.balance + (this.getUnconfirmedBalance() < 0 ? this.getUnconfirmedBalance() : 0); + const unconfirmed = this.getUnconfirmedBalance(); + return this.balance + (unconfirmed < 0 ? unconfirmed : 0); } getPreferredBalanceUnit(): BitcoinUnit { - for (const value of Object.values(BitcoinUnit)) { - if (value === this.preferredBalanceUnit) { - return this.preferredBalanceUnit; - } + if (Object.values(BitcoinUnit).includes(this.preferredBalanceUnit)) { + return this.preferredBalanceUnit; } return BitcoinUnit.BTC; } + setPreferredBalanceUnit(unit: BitcoinUnit): void { + if (Object.values(BitcoinUnit).includes(unit)) { + this.preferredBalanceUnit = unit; + return; + } + this.preferredBalanceUnit = BitcoinUnit.BTC; + } + async allowOnchainAddress(): Promise { throw new Error('allowOnchainAddress: Not implemented'); } @@ -163,11 +163,11 @@ export class AbstractWallet { return true; } - allowRBF(): boolean { + allowSilentPaymentSend(): boolean { return false; } - allowHodlHodlTrading(): boolean { + allowRBF(): boolean { return false; } @@ -219,10 +219,65 @@ export class AbstractWallet { } setSecret(newSecret: string): this { + const origSecret = newSecret; + + // is it minikey https://en.bitcoin.it/wiki/Mini_private_key_format + // Starts with S, is 22 length or larger, is base58 + if (newSecret.startsWith('S') && newSecret.length >= 22 && /^[1-9A-HJ-NP-Za-km-z]+$/.test(newSecret)) { + // minikey + ? hashed with SHA256 starts with 0x00 byte + if (uint8ArrayToHex(sha256(`${newSecret}?`)).startsWith('00')) { + // it is a valid minikey + newSecret = wif.encode(0x80, Buffer.from(sha256(newSecret)), false); + } + } + this.secret = newSecret.trim().replace('bitcoin:', '').replace('BITCOIN:', ''); if (this.secret.startsWith('BC1')) this.secret = this.secret.toLowerCase(); + // is it output descriptor? + if ( + this.secret.startsWith('wpkh(') || + this.secret.startsWith('pkh(') || + this.secret.startsWith('sh(') || + this.secret.startsWith('tr(') + ) { + const xpubIndex = Math.max(this.secret.indexOf('xpub'), this.secret.indexOf('ypub'), this.secret.indexOf('zpub')); + let fpAndPath; + if (this.secret.includes('[')) { + fpAndPath = this.secret.substring(this.secret.indexOf('['), xpubIndex).replace(/[[\]]/g, ''); + } else { + // old (or broken) format..? no square brackets, only "()" + fpAndPath = this.secret.substring(this.secret.indexOf('('), xpubIndex).replace(/[()]/g, ''); + } + const xpub = this.secret.substring(xpubIndex).replace(/[()]/g, '').split('/')[0]; + + const pathIndex = fpAndPath.indexOf('/'); + const path = 'm' + fpAndPath.substring(pathIndex).replace(/h/g, "'"); + const fp = fpAndPath.substring(0, pathIndex); + + this._derivationPath = path; + const mfp = uint8ArrayToHex(hexToUint8Array(fp).reverse()); + this.masterFingerprint = parseInt(mfp, 16); + + // Store the script type for later use + if (this.secret.startsWith('tr(')) { + this.segwitType = 'p2tr'; + this.secret = xpub; + } else if (this.secret.startsWith('wpkh(')) { + this.segwitType = 'p2wpkh'; + this.secret = this._xpubToZpub(xpub); + } else if (this.secret.startsWith('sh(wpkh(')) { + this.segwitType = 'p2sh(p2wpkh)'; + this.secret = this._xpubToYpub(xpub); + } else if (this.secret.startsWith('pkh(')) { + this.segwitType = 'p2pkh'; + this.secret = xpub; + } + + return this; + } + // [fingerprint/derivation]zpub const re = /\[([^\]]+)\](.*)/; const m = this.secret.match(re); @@ -230,7 +285,7 @@ export class AbstractWallet { let [hexFingerprint, ...derivationPathArray] = m[1].split('/'); const derivationPath = `m/${derivationPathArray.join('/').replace(/h/g, "'")}`; if (hexFingerprint.length === 8) { - hexFingerprint = Buffer.from(hexFingerprint, 'hex').reverse().toString('hex'); + hexFingerprint = uint8ArrayToHex(hexToUint8Array(hexFingerprint).reverse()); this.masterFingerprint = parseInt(hexFingerprint, 16); this._derivationPath = derivationPath; } @@ -238,7 +293,7 @@ export class AbstractWallet { if (derivationPath.startsWith("m/84'/0'/") && this.secret.toLowerCase().startsWith('xpub')) { // need to convert xpub to zpub - this.secret = this._xpubToZpub(this.secret); + this.secret = this._xpubToZpub(this.secret.split('/')[0]); } if (derivationPath.startsWith("m/49'/0'/") && this.secret.toLowerCase().startsWith('xpub')) { @@ -260,7 +315,7 @@ export class AbstractWallet { parsedSecret = JSON.parse(newSecret); } if (parsedSecret && parsedSecret.keystore && parsedSecret.keystore.xpub) { - let masterFingerprint: number | false = false; + let masterFingerprint: number = 0; if (parsedSecret.keystore.ckcc_xfp) { // It is a ColdCard Hardware Wallet masterFingerprint = Number(parsedSecret.keystore.ckcc_xfp); @@ -273,6 +328,7 @@ export class AbstractWallet { } if (parsedSecret.keystore.derivation) { this._derivationPath = parsedSecret.keystore.derivation; + this._derivationPath = this._derivationPath?.replace(/h/g, "'"); } this.secret = parsedSecret.keystore.xpub; this.masterFingerprint = masterFingerprint; @@ -282,12 +338,13 @@ export class AbstractWallet { // It is a Cobo Vault Hardware Wallet if (parsedSecret && parsedSecret.ExtPubKey && parsedSecret.MasterFingerprint && parsedSecret.AccountKeyPath) { this.secret = parsedSecret.ExtPubKey; - const mfp = Buffer.from(parsedSecret.MasterFingerprint, 'hex').reverse().toString('hex'); + const mfp = uint8ArrayToHex(hexToUint8Array(parsedSecret.MasterFingerprint).reverse()); this.masterFingerprint = parseInt(mfp, 16); this._derivationPath = parsedSecret.AccountKeyPath.startsWith('m/') ? parsedSecret.AccountKeyPath : `m/${parsedSecret.AccountKeyPath}`; if (parsedSecret.CoboVaultFirmwareVersion) this.use_with_hardware_wallet = true; + return this; } } catch (_) {} @@ -301,26 +358,17 @@ export class AbstractWallet { } } - // is it output descriptor? - if (this.secret.startsWith('wpkh(') || this.secret.startsWith('pkh(') || this.secret.startsWith('sh(')) { - const xpubIndex = Math.max(this.secret.indexOf('xpub'), this.secret.indexOf('ypub'), this.secret.indexOf('zpub')); - const fpAndPath = this.secret.substring(this.secret.indexOf('(') + 1, xpubIndex); - const xpub = this.secret.substring(xpubIndex).replace(/\(|\)/, ''); - const pathIndex = fpAndPath.indexOf('/'); - const path = 'm' + fpAndPath.substring(pathIndex); - const fp = fpAndPath.substring(0, pathIndex); - - this._derivationPath = path; - const mfp = Buffer.from(fp, 'hex').reverse().toString('hex'); - this.masterFingerprint = parseInt(mfp, 16); - - if (this.secret.startsWith('wpkh(')) { - this.secret = this._xpubToZpub(xpub); - } else { - // nop - this.secret = xpub; + // is it new-wasabi.json exported from coldcard? + try { + const json = JSON.parse(origSecret); + if (json.MasterFingerprint && json.ExtPubKey) { + // technically we should allow choosing which format user wants, BIP44 / BIP49 / BIP84, but meh... + this.secret = this._xpubToZpub(json.ExtPubKey); + const mfp = uint8ArrayToHex(hexToUint8Array(json.MasterFingerprint).reverse()); + this.masterFingerprint = parseInt(mfp, 16); + return this; } - } + } catch (_) {} return this; } @@ -329,17 +377,6 @@ export class AbstractWallet { return 0; } - getLatestTransactionTimeEpoch(): number { - if (this.getTransactions().length === 0) { - return 0; - } - let max = 0; - for (const tx of this.getTransactions()) { - max = Math.max(new Date(tx.received ?? 0).getTime(), max); - } - return max; - } - /** * @deprecated * TODO: be more precise on the type @@ -351,7 +388,7 @@ export class AbstractWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String}>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String}>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -380,11 +417,11 @@ export class AbstractWallet { } getAddressAsync(): Promise { - return new Promise(resolve => resolve(this.getAddress())); + return Promise.resolve(this.getAddress()); } async getChangeAddressAsync(): Promise { - return new Promise(resolve => resolve(this.getAddress())); + return Promise.resolve(this.getAddress()); } useWithHardwareWalletEnabled(): boolean { @@ -418,9 +455,9 @@ export class AbstractWallet { _zpubToXpub(zpub: string): string { let data = b58.decode(zpub); data = data.slice(4); - data = Buffer.concat([Buffer.from('0488b21e', 'hex'), data]); + const concatenated = concatUint8Arrays([hexToUint8Array('0488b21e'), data]); - return b58.encode(data); + return b58.encode(concatenated); } /** @@ -432,25 +469,25 @@ export class AbstractWallet { let data = b58.decode(ypub); if (data.readUInt32BE() !== 0x049d7cb2) throw new Error('Not a valid ypub extended key!'); data = data.slice(4); - data = Buffer.concat([Buffer.from('0488b21e', 'hex'), data]); + const concatenated = concatUint8Arrays([hexToUint8Array('0488b21e'), data]); - return b58.encode(data); + return b58.encode(concatenated); } _xpubToZpub(xpub: string): string { let data = b58.decode(xpub); data = data.slice(4); - data = Buffer.concat([Buffer.from('04b24746', 'hex'), data]); + const concatenated = concatUint8Arrays([hexToUint8Array('04b24746'), data]); - return b58.encode(data); + return b58.encode(concatenated); } _xpubToYpub(xpub: string): string { let data = b58.decode(xpub); data = data.slice(4); - data = Buffer.concat([Buffer.from('049d7cb2', 'hex'), data]); + const concatenated = concatUint8Arrays([hexToUint8Array('049d7cb2'), data]); - return b58.encode(data); + return b58.encode(concatenated); } prepareForSerialization(): void {} @@ -485,7 +522,7 @@ export class AbstractWallet { getMasterFingerprintFromHex(hexValue: string): number { if (hexValue.length < 8) hexValue = '0' + hexValue; - const b = Buffer.from(hexValue, 'hex'); + const b = hexToUint8Array(hexValue); if (b.length !== 4) throw new Error('invalid fingerprint hex'); hexValue = hexValue[6] + hexValue[7] + hexValue[4] + hexValue[5] + hexValue[2] + hexValue[3] + hexValue[0] + hexValue[1]; diff --git a/class/wallets/hd-aezeed-wallet.js b/class/wallets/hd-aezeed-wallet.js deleted file mode 100644 index 593e86c0ab9..00000000000 --- a/class/wallets/hd-aezeed-wallet.js +++ /dev/null @@ -1,191 +0,0 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import b58 from 'bs58check'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; - -const bitcoin = require('bitcoinjs-lib'); -const { CipherSeed } = require('aezeed'); -const bip32 = BIP32Factory(ecc); - -/** - * AEZEED mnemonics support, which is used in LND - * Support only BIP84 (native segwit) derivations - * - * @see https://github.com/lightningnetwork/lnd/tree/master/aezeed - * @see https://github.com/bitcoinjs/aezeed - * @see https://github.com/lightningnetwork/lnd/issues/4960 - * @see https://github.com/guggero/chantools/blob/master/doc/chantools_genimportscript.md - * @see https://github.com/lightningnetwork/lnd/blob/master/keychain/derivation.go - */ -export class HDAezeedWallet extends AbstractHDElectrumWallet { - static type = 'HDAezeedWallet'; - static typeReadable = 'HD Aezeed'; - static segwitType = 'p2wpkh'; - static derivationPath = "m/84'/0'/0'"; - - setSecret(newSecret) { - this.secret = newSecret.trim(); - this.secret = this.secret.replace(/[^a-zA-Z0-9]/g, ' ').replace(/\s+/g, ' '); - return this; - } - - _getEntropyCached() { - if (this._entropyHex) { - // cache hit - return Buffer.from(this._entropyHex, 'hex'); - } else { - throw new Error('Entropy cache is not filled'); - } - } - - getXpub() { - // first, getting xpub - const root = bip32.fromSeed(this._getEntropyCached()); - - const path = "m/84'/0'/0'"; - const child = root.derivePath(path).neutered(); - const xpub = child.toBase58(); - - // bitcoinjs does not support zpub yet, so we just convert it from xpub - let data = b58.decode(xpub); - data = data.slice(4); - data = Buffer.concat([Buffer.from('04b24746', 'hex'), data]); - this._xpub = b58.encode(data); - - return this._xpub; - } - - validateMnemonic() { - throw new Error('Use validateMnemonicAsync()'); - } - - async validateMnemonicAsync() { - const passphrase = this.getPassphrase() || 'aezeed'; - try { - const cipherSeed1 = await CipherSeed.fromMnemonic(this.secret, passphrase); - this._entropyHex = cipherSeed1.entropy.toString('hex'); // save cache - return !!cipherSeed1.entropy; - } catch (_) { - return false; - } - } - - async mnemonicInvalidPassword() { - const passphrase = this.getPassphrase() || 'aezeed'; - try { - const cipherSeed1 = await CipherSeed.fromMnemonic(this.secret, passphrase); - this._entropyHex = cipherSeed1.entropy.toString('hex'); // save cache - } catch (error) { - return error.message === 'Invalid Password'; - } - return false; - } - - async generate() { - throw new Error('Not implemented'); - } - - _getNode0() { - const root = bip32.fromSeed(this._getEntropyCached()); - const node = root.derivePath("m/84'/0'/0'"); - return node.derive(0); - } - - _getNode1() { - const root = bip32.fromSeed(this._getEntropyCached()); - const node = root.derivePath("m/84'/0'/0'"); - return node.derive(1); - } - - _getInternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - - this._node1 = this._node1 || this._getNode1(); // cache - - const address = bitcoin.payments.p2wpkh({ - pubkey: this._node1.derive(index).publicKey, - }).address; - - return (this.internal_addresses_cache[index] = address); - } - - _getExternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - - this._node0 = this._node0 || this._getNode0(); // cache - - const address = bitcoin.payments.p2wpkh({ - pubkey: this._node0.derive(index).publicKey, - }).address; - - return (this.external_addresses_cache[index] = address); - } - - _getWIFByIndex(internal, index) { - if (!this.secret) return false; - const root = bip32.fromSeed(this._getEntropyCached()); - const path = `m/84'/0'/0'/${internal ? 1 : 0}/${index}`; - const child = root.derivePath(path); - - return child.toWIF(); - } - - _getNodePubkeyByIndex(node, index) { - index = index * 1; // cast to int - - if (node === 0 && !this._node0) { - this._node0 = this._getNode0(); - } - - if (node === 1 && !this._node1) { - this._node1 = this._getNode1(); - } - - if (node === 0) { - return this._node0.derive(index).publicKey; - } - - if (node === 1) { - return this._node1.derive(index).publicKey; - } - } - - getIdentityPubkey() { - const root = bip32.fromSeed(this._getEntropyCached()); - const node = root.derivePath("m/1017'/0'/6'/0/0"); - - return node.publicKey.toString('hex'); - } - - // since its basically a bip84 wallet, we allow all other standard BIP84 features: - - allowSend() { - return true; - } - - allowHodlHodlTrading() { - return true; - } - - allowRBF() { - return true; - } - - allowPayJoin() { - return true; - } - - isSegwit() { - return true; - } - - allowSignVerifyMessage() { - return true; - } - - allowXpub() { - return true; - } -} diff --git a/class/wallets/hd-aezeed-wallet.ts b/class/wallets/hd-aezeed-wallet.ts new file mode 100644 index 00000000000..ae8f579226a --- /dev/null +++ b/class/wallets/hd-aezeed-wallet.ts @@ -0,0 +1,199 @@ +import { CipherSeed } from 'aezeed'; +import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; + +import ecc from '../../blue_modules/noble_ecc'; +import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; + +const bip32 = BIP32Factory(ecc); + +/** + * AEZEED mnemonics support, which is used in LND + * Support only BIP84 (native segwit) derivations + * + * @see https://github.com/lightningnetwork/lnd/tree/master/aezeed + * @see https://github.com/bitcoinjs/aezeed + * @see https://github.com/lightningnetwork/lnd/issues/4960 + * @see https://github.com/guggero/chantools/blob/master/doc/chantools_genimportscript.md + * @see https://github.com/lightningnetwork/lnd/blob/master/keychain/derivation.go + */ +export class HDAezeedWallet extends AbstractHDElectrumWallet { + static readonly type = 'HDAezeedWallet'; + static readonly typeReadable = 'HD Aezeed'; + public readonly segwitType = 'p2wpkh'; + static readonly derivationPath = "m/84'/0'/0'"; + // @ts-ignore: override + public readonly type = HDAezeedWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDAezeedWallet.typeReadable; + + private _entropyHex?: string; + + setSecret(newSecret: string): this { + this.secret = newSecret.trim(); + this.secret = this.secret.replace(/[^a-zA-Z0-9]/g, ' ').replace(/\s+/g, ' '); + return this; + } + + _getEntropyCached(): Uint8Array { + if (this._entropyHex) { + // cache hit + return hexToUint8Array(this._entropyHex); + } else { + throw new Error('Entropy cache is not filled'); + } + } + + getXpub() { + // first, getting xpub + const root = bip32.fromSeed(this._getEntropyCached()); + + const path = "m/84'/0'/0'"; + const child = root.derivePath(path).neutered(); + const xpub = child.toBase58(); + + // bitcoinjs does not support zpub yet, so we just convert it from xpub + this._xpub = this._xpubToZpub(xpub); + + return this._xpub; + } + + validateMnemonic(): boolean { + throw new Error('Use validateMnemonicAsync()'); + } + + async validateMnemonicAsync() { + const passphrase = this.getPassphrase() || 'aezeed'; + try { + const cipherSeed1 = await CipherSeed.fromMnemonic(this.secret, passphrase); + this._entropyHex = cipherSeed1.entropy.toString('hex'); // save cache + return !!cipherSeed1.entropy; + } catch (_) { + return false; + } + } + + async mnemonicInvalidPassword() { + const passphrase = this.getPassphrase() || 'aezeed'; + try { + const cipherSeed1 = await CipherSeed.fromMnemonic(this.secret, passphrase); + this._entropyHex = cipherSeed1.entropy.toString('hex'); // save cache + } catch (error: any) { + return error.message === 'Invalid Password'; + } + return false; + } + + async generate() { + throw new Error('Not implemented'); + } + + _getNode0() { + const root = bip32.fromSeed(this._getEntropyCached()); + const node = root.derivePath("m/84'/0'/0'"); + return node.derive(0); + } + + _getNode1() { + const root = bip32.fromSeed(this._getEntropyCached()); + const node = root.derivePath("m/84'/0'/0'"); + return node.derive(1); + } + + _getInternalAddressByIndex(index: number): string { + index = index * 1; // cast to int + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + + this._node1 = this._node1 || this._getNode1(); // cache + + const address = bitcoin.payments.p2wpkh({ + pubkey: this._node1.derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } + + return (this.internal_addresses_cache[index] = address); + } + + _getExternalAddressByIndex(index: number): string { + index = index * 1; // cast to int + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + + this._node0 = this._node0 || this._getNode0(); // cache + + const address = bitcoin.payments.p2wpkh({ + pubkey: this._node0.derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } + + return (this.external_addresses_cache[index] = address); + } + + _getWIFByIndex(internal: boolean, index: number): string | false { + if (!this.secret) return false; + const root = bip32.fromSeed(this._getEntropyCached()); + const path = `m/84'/0'/0'/${internal ? 1 : 0}/${index}`; + const child = root.derivePath(path); + + return child.toWIF(); + } + + _getNodePubkeyByIndex(node: number, index: number) { + index = index * 1; // cast to int + + if (node === 0 && !this._node0) { + this._node0 = this._getNode0(); + } + + if (node === 1 && !this._node1) { + this._node1 = this._getNode1(); + } + + if (node === 0 && this._node0) { + return this._node0.derive(index).publicKey; + } + + if (node === 1 && this._node1) { + return this._node1.derive(index).publicKey; + } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } + + getIdentityPubkey() { + const root = bip32.fromSeed(this._getEntropyCached()); + const node = root.derivePath("m/1017'/0'/6'/0/0"); + + return uint8ArrayToHex(node.publicKey); + } + + // since its basically a bip84 wallet, we allow all other standard BIP84 features: + + allowSend() { + return true; + } + + allowRBF() { + return true; + } + + allowPayJoin() { + return true; + } + + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return true; + } + + allowXpub() { + return true; + } +} diff --git a/class/wallets/hd-legacy-breadwallet-wallet.js b/class/wallets/hd-legacy-breadwallet-wallet.js deleted file mode 100644 index 5a6d5e92914..00000000000 --- a/class/wallets/hd-legacy-breadwallet-wallet.js +++ /dev/null @@ -1,149 +0,0 @@ -import * as bitcoinjs from 'bitcoinjs-lib'; -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; - -const BlueElectrum = require('../../blue_modules/BlueElectrum'); -const bip32 = BIP32Factory(ecc); - -/** - * HD Wallet (BIP39). - * In particular, Breadwallet-compatible (Legacy addresses) - */ -export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { - static type = 'HDLegacyBreadwallet'; - static typeReadable = 'HD Legacy Breadwallet (P2PKH)'; - static derivationPath = "m/0'"; - - // track address index at which wallet switched to segwit - _external_segwit_index = null; - _internal_segwit_index = null; - - // we need a separate function without external_addresses_cache to use in binarySearch - _calcNodeAddressByIndex(node, index, p2wpkh = false) { - let _node; - if (node === 0) { - _node = this._node0 || (this._node0 = bip32.fromBase58(this.getXpub()).derive(node)); - } - if (node === 1) { - _node = this._node1 || (this._node1 = bip32.fromBase58(this.getXpub()).derive(node)); - } - const pubkey = _node.derive(index).publicKey; - const address = p2wpkh ? bitcoinjs.payments.p2wpkh({ pubkey }).address : bitcoinjs.payments.p2pkh({ pubkey }).address; - return address; - } - - // this function is different from HDLegacyP2PKHWallet._getNodeAddressByIndex. - // It takes _external_segwit_index _internal_segwit_index for account - // and starts to generate segwit addresses if index more than them - _getNodeAddressByIndex(node, index) { - index = index * 1; // cast to int - if (node === 0) { - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - } - - if (node === 1) { - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - } - - let p2wpkh = false; - if ( - (node === 0 && this._external_segwit_index !== null && index >= this._external_segwit_index) || - (node === 1 && this._internal_segwit_index !== null && index >= this._internal_segwit_index) - ) { - p2wpkh = true; - } - - const address = this._calcNodeAddressByIndex(node, index, p2wpkh); - - if (node === 0) { - return (this.external_addresses_cache[index] = address); - } - - if (node === 1) { - return (this.internal_addresses_cache[index] = address); - } - } - - async fetchBalance() { - try { - if (this.next_free_change_address_index === 0 && this.next_free_address_index === 0) { - // doing binary search for last used addresses external/internal and legacy/bech32: - const [nextFreeExternalLegacy, nextFreeInternalLegacy] = await Promise.all([ - this._binarySearchIteration(0, 1000, 0, false), - this._binarySearchIteration(0, 1000, 1, false), - ]); - const [nextFreeExternalBech32, nextFreeInternalBech32] = await Promise.all([ - this._binarySearchIteration(nextFreeExternalLegacy, nextFreeExternalLegacy + 1000, 0, true), - this._binarySearchIteration(nextFreeInternalLegacy, nextFreeInternalLegacy + 1000, 1, true), - ]); - - // trying to detect if segwit activated. This condition can be deleted when BRD will enable segwit by default - if (nextFreeExternalLegacy < nextFreeExternalBech32) { - this._external_segwit_index = nextFreeExternalLegacy; - } - this.next_free_address_index = nextFreeExternalBech32; - - this._internal_segwit_index = nextFreeInternalLegacy; // force segwit for change - this.next_free_change_address_index = nextFreeInternalBech32; - } // end rescanning fresh wallet - - // finally fetching balance - await this._fetchBalance(); - } catch (err) { - console.warn(err); - } - } - - async _binarySearchIteration(startIndex, endIndex, node = 0, p2wpkh = false) { - const gerenateChunkAddresses = chunkNum => { - const ret = []; - for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { - ret.push(this._calcNodeAddressByIndex(node, c, p2wpkh)); - } - return ret; - }; - - let lastChunkWithUsedAddressesNum = null; - let lastHistoriesWithUsedAddresses = null; - for (let c = 0; c < Math.round(endIndex / this.gap_limit); c++) { - const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c)); - if (this.constructor._getTransactionsFromHistories(histories).length > 0) { - // in this particular chunk we have used addresses - lastChunkWithUsedAddressesNum = c; - lastHistoriesWithUsedAddresses = histories; - } else { - // empty chunk. no sense searching more chunks - break; - } - } - - let lastUsedIndex = startIndex; - - if (lastHistoriesWithUsedAddresses) { - // now searching for last used address in batch lastChunkWithUsedAddressesNum - for ( - let c = lastChunkWithUsedAddressesNum * this.gap_limit; - c < lastChunkWithUsedAddressesNum * this.gap_limit + this.gap_limit; - c++ - ) { - const address = this._calcNodeAddressByIndex(node, c, p2wpkh); - if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { - lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unsued - } - } - } - - return lastUsedIndex; - } - - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { - // hack to use - // AbstractHDElectrumWallet._addPsbtInput for bech32 address - // HDLegacyP2PKHWallet._addPsbtInput for legacy address - const ProxyClass = input.address.startsWith('bc1') ? AbstractHDElectrumWallet : HDLegacyP2PKHWallet; - const proxy = new ProxyClass(); - return proxy._addPsbtInput.apply(this, [psbt, input, sequence, masterFingerprintBuffer]); - } -} diff --git a/class/wallets/hd-legacy-breadwallet-wallet.ts b/class/wallets/hd-legacy-breadwallet-wallet.ts new file mode 100644 index 00000000000..afcd3166318 --- /dev/null +++ b/class/wallets/hd-legacy-breadwallet-wallet.ts @@ -0,0 +1,169 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import * as bitcoinjs from 'bitcoinjs-lib'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { ElectrumHistory } from '../../blue_modules/BlueElectrum'; +import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; + +const bip32 = BIP32Factory(ecc); + +/** + * HD Wallet (BIP39). + * In particular, Breadwallet-compatible (Legacy addresses) + */ +export class HDLegacyBreadwalletWallet extends HDLegacyP2PKHWallet { + static readonly type = 'HDLegacyBreadwallet'; + static readonly typeReadable = 'HD Legacy Breadwallet (P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyBreadwalletWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyBreadwalletWallet.typeReadable; + static readonly derivationPath = "m/0'"; + + // track address index at which wallet switched to segwit + _external_segwit_index: number | null = null; + _internal_segwit_index: number | null = null; + + // we need a separate function without external_addresses_cache to use in binarySearch + _calcNodeAddressByIndex(node: number, index: number, p2wpkh: boolean = false) { + let _node: BIP32Interface | undefined; + if (node === 0) { + _node = this._node0 || (this._node0 = bip32.fromBase58(this.getXpub()).derive(node)); + } + if (node === 1) { + _node = this._node1 || (this._node1 = bip32.fromBase58(this.getXpub()).derive(node)); + } + + if (!_node) { + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } + + const pubkey = _node.derive(index).publicKey; + const address = p2wpkh ? bitcoinjs.payments.p2wpkh({ pubkey }).address : bitcoinjs.payments.p2pkh({ pubkey }).address; + + if (!address) { + throw new Error('Internal error: no address in _calcNodeAddressByIndex'); + } + + return address; + } + + // this function is different from HDLegacyP2PKHWallet._getNodeAddressByIndex. + // It takes _external_segwit_index _internal_segwit_index for account + // and starts to generate segwit addresses if index more than them + _getNodeAddressByIndex(node: number, index: number): string { + index = index * 1; // cast to int + if (node === 0) { + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + } + + if (node === 1) { + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + } + + let p2wpkh = false; + if ( + (node === 0 && this._external_segwit_index !== null && index >= this._external_segwit_index) || + (node === 1 && this._internal_segwit_index !== null && index >= this._internal_segwit_index) + ) { + p2wpkh = true; + } + + const address = this._calcNodeAddressByIndex(node, index, p2wpkh); + + if (node === 0) { + return (this.external_addresses_cache[index] = address); + } + + if (node === 1) { + return (this.internal_addresses_cache[index] = address); + } + + throw new Error('Internal error: unknown node'); + } + + async fetchBalance() { + try { + if (this.next_free_change_address_index === 0 && this.next_free_address_index === 0) { + // doing binary search for last used addresses external/internal and legacy/bech32: + const [nextFreeExternalLegacy, nextFreeInternalLegacy] = await Promise.all([ + this._binarySearchIteration(0, 1000, 0, false), + this._binarySearchIteration(0, 1000, 1, false), + ]); + const [nextFreeExternalBech32, nextFreeInternalBech32] = await Promise.all([ + this._binarySearchIteration(nextFreeExternalLegacy, nextFreeExternalLegacy + 1000, 0, true), + this._binarySearchIteration(nextFreeInternalLegacy, nextFreeInternalLegacy + 1000, 1, true), + ]); + + // trying to detect if segwit activated. This condition can be deleted when BRD will enable segwit by default + if (nextFreeExternalLegacy < nextFreeExternalBech32) { + this._external_segwit_index = nextFreeExternalLegacy; + } + this.next_free_address_index = nextFreeExternalBech32; + + this._internal_segwit_index = nextFreeInternalLegacy; // force segwit for change + this.next_free_change_address_index = nextFreeInternalBech32; + } // end rescanning fresh wallet + + // finally fetching balance + await this._fetchBalance(); + } catch (err) { + console.warn(err); + } + } + + async _binarySearchIteration(startIndex: number, endIndex: number, node: number = 0, p2wpkh: boolean = false) { + const gerenateChunkAddresses = (chunkNum: number) => { + const ret = []; + for (let c = this.gap_limit * chunkNum; c < this.gap_limit * (chunkNum + 1); c++) { + ret.push(this._calcNodeAddressByIndex(node, c, p2wpkh)); + } + return ret; + }; + + let lastChunkWithUsedAddressesNum: number; + let lastHistoriesWithUsedAddresses: Record; + for (let c = 0; c < Math.round(endIndex / this.gap_limit); c++) { + const histories = await BlueElectrum.multiGetHistoryByAddress(gerenateChunkAddresses(c)); + if (AbstractHDElectrumWallet._getTransactionsFromHistories(histories).length > 0) { + // in this particular chunk we have used addresses + lastChunkWithUsedAddressesNum = c; + lastHistoriesWithUsedAddresses = histories; + } else { + // empty chunk. no sense searching more chunks + break; + } + } + + let lastUsedIndex = startIndex; + + if (lastHistoriesWithUsedAddresses!) { + // now searching for last used address in batch lastChunkWithUsedAddressesNum + for ( + let c = lastChunkWithUsedAddressesNum! * this.gap_limit; + c < lastChunkWithUsedAddressesNum! * this.gap_limit + this.gap_limit; + c++ + ) { + const address = this._calcNodeAddressByIndex(node, c, p2wpkh); + if (lastHistoriesWithUsedAddresses[address] && lastHistoriesWithUsedAddresses[address].length > 0) { + lastUsedIndex = Math.max(c, lastUsedIndex) + 1; // point to next, which is supposed to be unsued + } + } + } + + return lastUsedIndex; + } + + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Uint8Array) { + // AbstractHDElectrumWallet._addPsbtInput for bech32 address + // HDLegacyP2PKHWallet._addPsbtInput for legacy address + if (input?.address?.startsWith('bc1')) { + return AbstractHDElectrumWallet.prototype._addPsbtInput.call(this, psbt, input, sequence, masterFingerprintBuffer); + } + return super._addPsbtInput(psbt, input, sequence, masterFingerprintBuffer); + } +} diff --git a/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js b/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js deleted file mode 100644 index ea46112434c..00000000000 --- a/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.js +++ /dev/null @@ -1,103 +0,0 @@ -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; - -const bitcoin = require('bitcoinjs-lib'); -const mn = require('electrum-mnemonic'); -const bip32 = BIP32Factory(ecc); - -const PREFIX = mn.PREFIXES.standard; - -/** - * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise - * its a regular HD wallet that has all the properties of parent class. - * - * @see https://electrum.readthedocs.io/en/latest/seedphrase.html - */ -export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { - static type = 'HDlegacyElectrumSeedP2PKH'; - static typeReadable = 'HD Legacy Electrum (BIP32 P2PKH)'; - static derivationPath = 'm'; - - validateMnemonic() { - return mn.validateMnemonic(this.secret, PREFIX); - } - - allowBIP47() { - return false; - } - - async generate() { - throw new Error('Not implemented'); - } - - getXpub() { - if (this._xpub) { - return this._xpub; // cache hit - } - const args = { prefix: PREFIX }; - if (this.passphrase) args.passphrase = this.passphrase; - const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); - this._xpub = root.neutered().toBase58(); - return this._xpub; - } - - _getInternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - - const node = bip32.fromBase58(this.getXpub()); - const address = bitcoin.payments.p2pkh({ - pubkey: node.derive(1).derive(index).publicKey, - }).address; - - return (this.internal_addresses_cache[index] = address); - } - - _getExternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - - const node = bip32.fromBase58(this.getXpub()); - const address = bitcoin.payments.p2pkh({ - pubkey: node.derive(0).derive(index).publicKey, - }).address; - - return (this.external_addresses_cache[index] = address); - } - - _getWIFByIndex(internal, index) { - if (!this.secret) return false; - const args = { prefix: PREFIX }; - if (this.passphrase) args.passphrase = this.passphrase; - const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); - const path = `m/${internal ? 1 : 0}/${index}`; - const child = root.derivePath(path); - - return child.toWIF(); - } - - _getNodePubkeyByIndex(node, index) { - index = index * 1; // cast to int - - if (node === 0 && !this._node0) { - const xpub = this.getXpub(); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(node); - } - - if (node === 1 && !this._node1) { - const xpub = this.getXpub(); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(node); - } - - if (node === 0) { - return this._node0.derive(index).publicKey; - } - - if (node === 1) { - return this._node1.derive(index).publicKey; - } - } -} diff --git a/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts b/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts new file mode 100644 index 00000000000..6ce6aac804d --- /dev/null +++ b/class/wallets/hd-legacy-electrum-seed-p2pkh-wallet.ts @@ -0,0 +1,120 @@ +import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import * as mn from 'electrum-mnemonic'; + +import ecc from '../../blue_modules/noble_ecc'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; + +const bip32 = BIP32Factory(ecc); +const PREFIX = mn.PREFIXES.standard; + +type SeedOpts = { + prefix?: string; + passphrase?: string; +}; + +/** + * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise + * its a regular HD wallet that has all the properties of parent class. + * + * @see https://electrum.readthedocs.io/en/latest/seedphrase.html + */ +export class HDLegacyElectrumSeedP2PKHWallet extends HDLegacyP2PKHWallet { + static readonly type = 'HDlegacyElectrumSeedP2PKH'; + static readonly typeReadable = 'HD Legacy Electrum (BIP32 P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyElectrumSeedP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyElectrumSeedP2PKHWallet.typeReadable; + static readonly derivationPath = 'm'; + + validateMnemonic() { + return mn.validateMnemonic(this.secret, PREFIX); + } + + allowBIP47() { + return false; + } + + async generate() { + throw new Error('Not implemented'); + } + + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + const args: SeedOpts = { prefix: PREFIX }; + if (this.passphrase) args.passphrase = this.passphrase; + const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); + this._xpub = root.neutered().toBase58(); + return this._xpub; + } + + _getInternalAddressByIndex(index: number) { + index = index * 1; // cast to int + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + + const node = bip32.fromBase58(this.getXpub()); + const address = bitcoin.payments.p2pkh({ + pubkey: node.derive(1).derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } + + return (this.internal_addresses_cache[index] = address); + } + + _getExternalAddressByIndex(index: number) { + index = index * 1; // cast to int + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + + const node = bip32.fromBase58(this.getXpub()); + const address = bitcoin.payments.p2pkh({ + pubkey: node.derive(0).derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } + + return (this.external_addresses_cache[index] = address); + } + + _getWIFByIndex(internal: boolean, index: number): string | false { + if (!this.secret) return false; + const args: SeedOpts = { prefix: PREFIX }; + if (this.passphrase) args.passphrase = this.passphrase; + const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); + const path = `m/${internal ? 1 : 0}/${index}`; + const child = root.derivePath(path); + + return child.toWIF(); + } + + _getNodePubkeyByIndex(node: number, index: number) { + index = index * 1; // cast to int + + if (node === 0 && !this._node0) { + const xpub = this.getXpub(); + const hdNode = bip32.fromBase58(xpub); + this._node0 = hdNode.derive(node); + } + + if (node === 1 && !this._node1) { + const xpub = this.getXpub(); + const hdNode = bip32.fromBase58(xpub); + this._node1 = hdNode.derive(node); + } + + if (node === 0 && this._node0) { + return this._node0.derive(index).publicKey; + } + + if (node === 1 && this._node1) { + return this._node1.derive(index).publicKey; + } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } +} diff --git a/class/wallets/hd-legacy-p2pkh-wallet.js b/class/wallets/hd-legacy-p2pkh-wallet.js deleted file mode 100644 index 088db99b5b1..00000000000 --- a/class/wallets/hd-legacy-p2pkh-wallet.js +++ /dev/null @@ -1,100 +0,0 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; -const bip32 = BIP32Factory(ecc); -const BlueElectrum = require('../../blue_modules/BlueElectrum'); - -/** - * HD Wallet (BIP39). - * In particular, BIP44 (P2PKH legacy addressess) - * @see https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki - */ -export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { - static type = 'HDlegacyP2PKH'; - static typeReadable = 'HD Legacy (BIP44 P2PKH)'; - static derivationPath = "m/44'/0'/0'"; - - allowSend() { - return true; - } - - allowCosignPsbt() { - return true; - } - - allowSignVerifyMessage() { - return true; - } - - allowMasterFingerprint() { - return true; - } - - allowXpub() { - return true; - } - - allowBIP47() { - return true; - } - - getXpub() { - if (this._xpub) { - return this._xpub; // cache hit - } - const seed = this._getSeed(); - const root = bip32.fromSeed(seed); - - const path = this.getDerivationPath(); - const child = root.derivePath(path).neutered(); - this._xpub = child.toBase58(); - - return this._xpub; - } - - _hdNodeToAddress(hdNode) { - return this._nodeToLegacyAddress(hdNode); - } - - async fetchUtxo() { - await super.fetchUtxo(); - // now we need to fetch txhash for each input as required by PSBT - const txhexes = await BlueElectrum.multiGetTransactionByTxid( - this.getUtxo().map(x => x.txid), - 50, - false, - ); - - const newUtxos = []; - for (const u of this.getUtxo()) { - if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; - newUtxos.push(u); - } - - return newUtxos; - } - - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { - const pubkey = this._getPubkeyByAddress(input.address); - const path = this._getDerivationPathByAddress(input.address, 44); - - if (!input.txhex) throw new Error('UTXO is missing txhex of the input, which is required by PSBT for non-segwit input'); - - psbt.addInput({ - hash: input.txid, - index: input.vout, - sequence, - bip32Derivation: [ - { - masterFingerprint: masterFingerprintBuffer, - path, - pubkey, - }, - ], - // non-segwit inputs now require passing the whole previous tx as Buffer - nonWitnessUtxo: Buffer.from(input.txhex, 'hex'), - }); - - return psbt; - } -} diff --git a/class/wallets/hd-legacy-p2pkh-wallet.ts b/class/wallets/hd-legacy-p2pkh-wallet.ts new file mode 100644 index 00000000000..775ade08fe2 --- /dev/null +++ b/class/wallets/hd-legacy-p2pkh-wallet.ts @@ -0,0 +1,118 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import { hexToUint8Array } from '../../blue_modules/uint8array-extras'; + +const bip32 = BIP32Factory(ecc); + +/** + * HD Wallet (BIP39). + * In particular, BIP44 (P2PKH legacy addressess) + * @see https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki + */ +export class HDLegacyP2PKHWallet extends AbstractHDElectrumWallet { + static readonly type = 'HDlegacyP2PKH'; + static readonly typeReadable = 'HD Legacy (BIP44 P2PKH)'; + // @ts-ignore: override + public readonly type = HDLegacyP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDLegacyP2PKHWallet.typeReadable; + static readonly derivationPath = "m/44'/0'/0'"; + + allowSend() { + return true; + } + + allowCosignPsbt() { + return true; + } + + allowSignVerifyMessage() { + return true; + } + + allowMasterFingerprint() { + return true; + } + + allowXpub() { + return true; + } + + allowBIP47() { + return true; + } + + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + const seed = this._getSeed(); + const root = bip32.fromSeed(seed); + + const path = this.getDerivationPath(); + if (!path) { + throw new Error('Internal error: no path'); + } + const child = root.derivePath(path).neutered(); + this._xpub = child.toBase58(); + + return this._xpub; + } + + _hdNodeToAddress(hdNode: BIP32Interface): string { + return this._nodeToLegacyAddress(hdNode); + } + + async fetchUtxo(): Promise { + await super.fetchUtxo(); + // now we need to fetch txhash for each input as required by PSBT + const utxos = this.getUtxo(); + const txhexes = await BlueElectrum.multiGetTransactionByTxid( + utxos.map(x => x.txid), + false, + ); + + for (const u of utxos) { + if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; + } + } + + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Uint8Array) { + if (!input.address) { + throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); + } + const pubkey = this._getPubkeyByAddress(input.address); + const path = this._getDerivationPathByAddress(input.address); + if (!pubkey || !path) { + throw new Error('Internal error: pubkey or path are invalid'); + } + + if (!input.txhex) throw new Error('UTXO is missing txhex of the input, which is required by PSBT for non-segwit input'); + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + bip32Derivation: [ + { + masterFingerprint: masterFingerprintBuffer, + path, + pubkey, + }, + ], + // non-segwit inputs now require passing the whole previous tx as Buffer + nonWitnessUtxo: hexToUint8Array(input.txhex), + }); + + return psbt; + } + + allowSilentPaymentSend(): boolean { + return true; + } +} diff --git a/class/wallets/hd-segwit-bech32-wallet.js b/class/wallets/hd-segwit-bech32-wallet.js deleted file mode 100644 index e86d158ed74..00000000000 --- a/class/wallets/hd-segwit-bech32-wallet.js +++ /dev/null @@ -1,53 +0,0 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; - -/** - * HD Wallet (BIP39). - * In particular, BIP84 (Bech32 Native Segwit) - * @see https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki - */ -export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet { - static type = 'HDsegwitBech32'; - static typeReadable = 'HD SegWit (BIP84 Bech32 Native)'; - static segwitType = 'p2wpkh'; - static derivationPath = "m/84'/0'/0'"; - - allowSend() { - return true; - } - - allowHodlHodlTrading() { - return true; - } - - allowRBF() { - return true; - } - - allowPayJoin() { - return true; - } - - allowCosignPsbt() { - return true; - } - - isSegwit() { - return true; - } - - allowSignVerifyMessage() { - return true; - } - - allowMasterFingerprint() { - return true; - } - - allowXpub() { - return true; - } - - allowBIP47() { - return true; - } -} diff --git a/class/wallets/hd-segwit-bech32-wallet.ts b/class/wallets/hd-segwit-bech32-wallet.ts new file mode 100644 index 00000000000..bb51a22c203 --- /dev/null +++ b/class/wallets/hd-segwit-bech32-wallet.ts @@ -0,0 +1,57 @@ +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; + +/** + * HD Wallet (BIP39). + * In particular, BIP84 (Bech32 Native Segwit) + * @see https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki + */ +export class HDSegwitBech32Wallet extends AbstractHDElectrumWallet { + static readonly type = 'HDsegwitBech32'; + static readonly typeReadable = 'HD SegWit (BIP84 Bech32 Native)'; + // @ts-ignore: override + public readonly type = HDSegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitBech32Wallet.typeReadable; + public readonly segwitType = 'p2wpkh'; + static readonly derivationPath = "m/84'/0'/0'"; + + allowSend() { + return true; + } + + allowRBF() { + return true; + } + + allowPayJoin() { + return true; + } + + allowCosignPsbt() { + return true; + } + + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return true; + } + + allowMasterFingerprint() { + return true; + } + + allowXpub() { + return true; + } + + allowBIP47() { + return true; + } + + allowSilentPaymentSend(): boolean { + return true; + } +} diff --git a/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js b/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js deleted file mode 100644 index 469830a10ea..00000000000 --- a/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.js +++ /dev/null @@ -1,117 +0,0 @@ -import b58 from 'bs58check'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; - -const bitcoin = require('bitcoinjs-lib'); -const mn = require('electrum-mnemonic'); -const bip32 = BIP32Factory(ecc); - -const PREFIX = mn.PREFIXES.segwit; - -/** - * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise - * its a regular HD wallet that has all the properties of parent class. - * - * @see https://electrum.readthedocs.io/en/latest/seedphrase.html - */ -export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { - static type = 'HDSegwitElectrumSeedP2WPKHWallet'; - static typeReadable = 'HD Electrum (BIP32 P2WPKH)'; - static derivationPath = "m/0'"; - - validateMnemonic() { - return mn.validateMnemonic(this.secret, PREFIX); - } - - allowBIP47() { - return false; - } - - async generate() { - throw new Error('Not implemented'); - } - - getXpub() { - if (this._xpub) { - return this._xpub; // cache hit - } - const args = { prefix: PREFIX }; - if (this.passphrase) args.passphrase = this.passphrase; - const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); - const xpub = root.derivePath("m/0'").neutered().toBase58(); - - // bitcoinjs does not support zpub yet, so we just convert it from xpub - let data = b58.decode(xpub); - data = data.slice(4); - data = Buffer.concat([Buffer.from('04b24746', 'hex'), data]); - this._xpub = b58.encode(data); - - return this._xpub; - } - - _getInternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - - const xpub = this._zpubToXpub(this.getXpub()); - const node = bip32.fromBase58(xpub); - const address = bitcoin.payments.p2wpkh({ - pubkey: node.derive(1).derive(index).publicKey, - }).address; - - return (this.internal_addresses_cache[index] = address); - } - - _getExternalAddressByIndex(index) { - index = index * 1; // cast to int - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - - const xpub = this._zpubToXpub(this.getXpub()); - const node = bip32.fromBase58(xpub); - const address = bitcoin.payments.p2wpkh({ - pubkey: node.derive(0).derive(index).publicKey, - }).address; - - return (this.external_addresses_cache[index] = address); - } - - _getWIFByIndex(internal, index) { - if (!this.secret) return false; - const args = { prefix: PREFIX }; - if (this.passphrase) args.passphrase = this.passphrase; - const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); - const path = `m/0'/${internal ? 1 : 0}/${index}`; - const child = root.derivePath(path); - - return child.toWIF(); - } - - _getNodePubkeyByIndex(node, index) { - index = index * 1; // cast to int - - if (node === 0 && !this._node0) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node0 = hdNode.derive(node); - } - - if (node === 1 && !this._node1) { - const xpub = this._zpubToXpub(this.getXpub()); - const hdNode = bip32.fromBase58(xpub); - this._node1 = hdNode.derive(node); - } - - if (node === 0) { - return this._node0.derive(index).publicKey; - } - - if (node === 1) { - return this._node1.derive(index).publicKey; - } - } - - isSegwit() { - return true; - } -} diff --git a/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts b/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts new file mode 100644 index 00000000000..08327f34fd1 --- /dev/null +++ b/class/wallets/hd-segwit-electrum-seed-p2wpkh-wallet.ts @@ -0,0 +1,130 @@ +import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import * as mn from 'electrum-mnemonic'; + +import ecc from '../../blue_modules/noble_ecc'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; + +const bip32 = BIP32Factory(ecc); +const PREFIX = mn.PREFIXES.segwit; + +type SeedOpts = { + prefix?: string; + passphrase?: string; +}; + +/** + * ElectrumSeed means that instead of BIP39 seed format it works with the format invented by Electrum wallet. Otherwise + * its a regular HD wallet that has all the properties of parent class. + * + * @see https://electrum.readthedocs.io/en/latest/seedphrase.html + */ +export class HDSegwitElectrumSeedP2WPKHWallet extends HDSegwitBech32Wallet { + static readonly type = 'HDSegwitElectrumSeedP2WPKHWallet'; + static readonly typeReadable = 'HD Electrum (BIP32 P2WPKH)'; + // @ts-ignore: override + public readonly type = HDSegwitElectrumSeedP2WPKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitElectrumSeedP2WPKHWallet.typeReadable; + static readonly derivationPath = "m/0'"; + + validateMnemonic() { + return mn.validateMnemonic(this.secret, PREFIX); + } + + allowBIP47() { + return false; + } + + async generate() { + throw new Error('Not implemented'); + } + + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + const args: SeedOpts = { prefix: PREFIX }; + if (this.passphrase) args.passphrase = this.passphrase; + const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); + const xpub = root.derivePath("m/0'").neutered().toBase58(); + + // bitcoinjs does not support zpub yet, so we just convert it from xpub + this._xpub = this._xpubToZpub(xpub); + + return this._xpub; + } + + _getInternalAddressByIndex(index: number) { + index = index * 1; // cast to int + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + + const xpub = this._zpubToXpub(this.getXpub()); + const node = bip32.fromBase58(xpub); + const address = bitcoin.payments.p2wpkh({ + pubkey: node.derive(1).derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getInternalAddressByIndex'); + } + + return (this.internal_addresses_cache[index] = address); + } + + _getExternalAddressByIndex(index: number) { + index = index * 1; // cast to int + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + + const xpub = this._zpubToXpub(this.getXpub()); + const node = bip32.fromBase58(xpub); + const address = bitcoin.payments.p2wpkh({ + pubkey: node.derive(0).derive(index).publicKey, + }).address; + if (!address) { + throw new Error('Internal error: no address in _getExternalAddressByIndex'); + } + + return (this.external_addresses_cache[index] = address); + } + + _getWIFByIndex(internal: boolean, index: number): string | false { + if (!this.secret) return false; + const args: SeedOpts = { prefix: PREFIX }; + if (this.passphrase) args.passphrase = this.passphrase; + const root = bip32.fromSeed(mn.mnemonicToSeedSync(this.secret, args)); + const path = `m/0'/${internal ? 1 : 0}/${index}`; + const child = root.derivePath(path); + + return child.toWIF(); + } + + _getNodePubkeyByIndex(node: number, index: number) { + index = index * 1; // cast to int + + if (node === 0 && !this._node0) { + const xpub = this._zpubToXpub(this.getXpub()); + const hdNode = bip32.fromBase58(xpub); + this._node0 = hdNode.derive(node); + } + + if (node === 1 && !this._node1) { + const xpub = this._zpubToXpub(this.getXpub()); + const hdNode = bip32.fromBase58(xpub); + this._node1 = hdNode.derive(node); + } + + if (node === 0 && this._node0) { + return this._node0.derive(index).publicKey; + } + + if (node === 1 && this._node1) { + return this._node1.derive(index).publicKey; + } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } + + isSegwit() { + return true; + } +} diff --git a/class/wallets/hd-segwit-p2sh-wallet.js b/class/wallets/hd-segwit-p2sh-wallet.js deleted file mode 100644 index 5ed8171502a..00000000000 --- a/class/wallets/hd-segwit-p2sh-wallet.js +++ /dev/null @@ -1,104 +0,0 @@ -import b58 from 'bs58check'; -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; -const bip32 = BIP32Factory(ecc); -const bitcoin = require('bitcoinjs-lib'); - -/** - * HD Wallet (BIP39). - * In particular, BIP49 (P2SH Segwit) - * @see https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki - */ -export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { - static type = 'HDsegwitP2SH'; - static typeReadable = 'HD SegWit (BIP49 P2SH)'; - static segwitType = 'p2sh(p2wpkh)'; - static derivationPath = "m/49'/0'/0'"; - - allowSend() { - return true; - } - - allowCosignPsbt() { - return true; - } - - allowSignVerifyMessage() { - return true; - } - - allowHodlHodlTrading() { - return true; - } - - allowMasterFingerprint() { - return true; - } - - allowXpub() { - return true; - } - - _hdNodeToAddress(hdNode) { - return this._nodeToP2shSegwitAddress(hdNode); - } - - /** - * Returning ypub actually, not xpub. Keeping same method name - * for compatibility. - * - * @return {String} ypub - */ - getXpub() { - if (this._xpub) { - return this._xpub; // cache hit - } - // first, getting xpub - const seed = this._getSeed(); - const root = bip32.fromSeed(seed); - - const path = this.getDerivationPath(); - const child = root.derivePath(path).neutered(); - const xpub = child.toBase58(); - - // bitcoinjs does not support ypub yet, so we just convert it from xpub - let data = b58.decode(xpub); - data = data.slice(4); - data = Buffer.concat([Buffer.from('049d7cb2', 'hex'), data]); - this._xpub = b58.encode(data); - - return this._xpub; - } - - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { - const pubkey = this._getPubkeyByAddress(input.address); - const path = this._getDerivationPathByAddress(input.address); - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); - const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); - - psbt.addInput({ - hash: input.txid, - index: input.vout, - sequence, - bip32Derivation: [ - { - masterFingerprint: masterFingerprintBuffer, - path, - pubkey, - }, - ], - witnessUtxo: { - script: p2sh.output, - value: input.amount || input.value, - }, - redeemScript: p2wpkh.output, - }); - - return psbt; - } - - isSegwit() { - return true; - } -} diff --git a/class/wallets/hd-segwit-p2sh-wallet.ts b/class/wallets/hd-segwit-p2sh-wallet.ts new file mode 100644 index 00000000000..a8440f20289 --- /dev/null +++ b/class/wallets/hd-segwit-p2sh-wallet.ts @@ -0,0 +1,120 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +import ecc from '../../blue_modules/noble_ecc'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; + +const bip32 = BIP32Factory(ecc); + +/** + * HD Wallet (BIP39). + * In particular, BIP49 (P2SH Segwit) + * @see https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + */ +export class HDSegwitP2SHWallet extends AbstractHDElectrumWallet { + static readonly type = 'HDsegwitP2SH'; + static readonly typeReadable = 'HD SegWit (BIP49 P2SH)'; + // @ts-ignore: override + public readonly type = HDSegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDSegwitP2SHWallet.typeReadable; + public readonly segwitType = 'p2sh(p2wpkh)'; + static readonly derivationPath = "m/49'/0'/0'"; + + allowSend() { + return true; + } + + allowCosignPsbt() { + return true; + } + + allowSignVerifyMessage() { + return true; + } + + allowMasterFingerprint() { + return true; + } + + allowXpub() { + return true; + } + + _hdNodeToAddress(hdNode: BIP32Interface): string { + return this._nodeToP2shSegwitAddress(hdNode); + } + + /** + * Returning ypub actually, not xpub. Keeping same method name + * for compatibility. + * + * @return {String} ypub + */ + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + // first, getting xpub + const seed = this._getSeed(); + const root = bip32.fromSeed(seed); + + const path = this.getDerivationPath(); + if (!path) { + throw new Error('Internal error: no path'); + } + const child = root.derivePath(path).neutered(); + const xpub = child.toBase58(); + + // bitcoinjs does not support ypub yet, so we just convert it from xpub + this._xpub = this._xpubToYpub(xpub); + + return this._xpub; + } + + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Uint8Array) { + if (!input.address) { + throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); + } + const pubkey = this._getPubkeyByAddress(input.address); + const path = this._getDerivationPathByAddress(input.address); + if (!pubkey || !path) { + throw new Error('Internal error: pubkey or path are invalid'); + } + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); + if (!p2sh.output) { + throw new Error('Internal error: no p2sh.output during _addPsbtInput()'); + } + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + bip32Derivation: [ + { + masterFingerprint: masterFingerprintBuffer, + path, + pubkey, + }, + ], + witnessUtxo: { + script: p2sh.output, + value: BigInt(input.value), + }, + redeemScript: p2wpkh.output, + }); + + return psbt; + } + + isSegwit() { + return true; + } + + allowSilentPaymentSend(): boolean { + return true; + } +} diff --git a/class/wallets/hd-taproot-wallet.ts b/class/wallets/hd-taproot-wallet.ts new file mode 100644 index 00000000000..8d0851b1a2a --- /dev/null +++ b/class/wallets/hd-taproot-wallet.ts @@ -0,0 +1,166 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import ecc from '../../blue_modules/noble_ecc'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Psbt } from 'bitcoinjs-lib'; +import { CoinSelectReturnInput } from 'coinselect'; + +const bip32 = BIP32Factory(ecc); + +/** + * @see https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + */ +export class HDTaprootWallet extends AbstractHDElectrumWallet { + static readonly type = 'HDtaproot'; + static readonly typeReadable = 'HD Taproot (BIP86)'; + // @ts-ignore: override + public readonly type = HDTaprootWallet.type; + // @ts-ignore: override + public readonly typeReadable = HDTaprootWallet.typeReadable; + public readonly segwitType = 'p2tr'; + static readonly derivationPath = "m/86'/0'/0'"; + + getXpub() { + if (this._xpub) { + return this._xpub; // cache hit + } + const seed = this._getSeed(); + const root = bip32.fromSeed(seed); + + const path = this.getDerivationPath(); + if (!path) { + throw new Error('Internal error: no path'); + } + const child = root.derivePath(path).neutered(); + const xpub = child.toBase58(); + this._xpub = xpub; + + // returning regular xpub since industry standard is to use regular xpubs for Taproot wallets without any + // kind of prefix change (like ypub or zpub) + return xpub; + } + + _hdNodeToAddress(hdNode: BIP32Interface): string { + return this._nodeToTaprootAddress(hdNode); + } + + _nodeToTaprootAddress(hdNode: BIP32Interface): string { + const xOnlyPubkey = hdNode.publicKey.subarray(1, 33); + + const { address } = bitcoin.payments.p2tr({ + internalPubkey: xOnlyPubkey, + }); + + if (!address) { + throw new Error('Could not create address in _nodeToTaprootAddress'); + } + + return address; + } + + _getNodePubkeyByIndex(node: number, index: number) { + index = index * 1; // cast to int + + if (node === 0 && !this._node0) { + let xpub = this.getXpub(); + if (xpub.startsWith('zpub')) { + // bip32.fromBase58() wont work with zpub prefix, need to swap it for the traditional one + xpub = this._zpubToXpub(xpub); + } + const hdNode = bip32.fromBase58(xpub); + this._node0 = hdNode.derive(node); + } + + if (node === 1 && !this._node1) { + let xpub = this.getXpub(); + if (xpub.startsWith('zpub')) { + // bip32.fromBase58() wont work with zpub prefix, need to swap it for the traditional one + xpub = this._zpubToXpub(xpub); + } + const hdNode = bip32.fromBase58(xpub); + this._node1 = hdNode.derive(node); + } + + if (node === 0 && this._node0) { + return this._node0.derive(index).publicKey.subarray(1, 33); + } + + if (node === 1 && this._node1) { + return this._node1.derive(index).publicKey.subarray(1, 33); + } + + throw new Error('Internal error: this._node0 or this._node1 is undefined'); + } + + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer: Buffer) { + if (!input.address) { + throw new Error('Internal error: no address on Utxo during _addPsbtInput()'); + } + const pubkey = this._getPubkeyByAddress(input.address); + const path = this._getDerivationPathByAddress(input.address); + if (!pubkey || !path) { + throw new Error('Internal error: pubkey or path are invalid'); + } + + const p2tr = bitcoin.payments.p2tr({ + internalPubkey: pubkey, + }); + if (!p2tr.output) throw new Error('Could not build p2tr.output'); + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + witnessUtxo: { + script: p2tr.output!, + value: BigInt(input.value), + }, + tapBip32Derivation: [ + { + pubkey: new Uint8Array(pubkey), + masterFingerprint: new Uint8Array(masterFingerprintBuffer), + path, + leafHashes: [], + }, + ], + + // tell PSBT it’s a key-path Taproot spend + tapInternalKey: pubkey, + }); + + return psbt; + } + + allowSend() { + return true; + } + + allowCosignPsbt() { + return true; + } + + // is it even used anywhere..? + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return false; + } + + allowMasterFingerprint() { + return true; + } + + allowXpub() { + return true; + } + + allowBIP47() { + return true; + } + + allowSilentPaymentSend(): boolean { + return true; + } +} diff --git a/class/wallets/legacy-wallet.ts b/class/wallets/legacy-wallet.ts index e39ae85809e..e0976983849 100644 --- a/class/wallets/legacy-wallet.ts +++ b/class/wallets/legacy-wallet.ts @@ -1,16 +1,17 @@ import BigNumber from 'bignumber.js'; -import bitcoinMessage from 'bitcoinjs-message'; -import { randomBytes } from '../rng'; -import { AbstractWallet } from './abstract-wallet'; -import { HDSegwitBech32Wallet } from '..'; import * as bitcoin from 'bitcoinjs-lib'; -import * as BlueElectrum from '../../blue_modules/BlueElectrum'; -import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget, CoinSelectUtxo } from 'coinselect'; +import bitcoinMessage from 'bitcoinjs-message'; +import coinSelect, { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; import coinSelectSplit from 'coinselect/split'; -import { CreateTransactionResult, CreateTransactionUtxo, Transaction, Utxo } from './types'; import { ECPairAPI, ECPairFactory, Signer } from 'ecpair'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; import ecc from '../../blue_modules/noble_ecc'; +import { hexToUint8Array, concatUint8Arrays } from '../../blue_modules/uint8array-extras'; +import type { HDSegwitBech32Wallet as HDSegwitBech32WalletT } from './hd-segwit-bech32-wallet'; +import { randomBytes } from '../rng'; +import { AbstractWallet } from './abstract-wallet'; +import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo, Transaction, Utxo } from './types'; const ECPair: ECPairAPI = ECPairFactory(ecc); bitcoin.initEccLib(ecc); @@ -19,11 +20,21 @@ bitcoin.initEccLib(ecc); * (legacy P2PKH compressed) */ export class LegacyWallet extends AbstractWallet { - static type = 'legacy'; - static typeReadable = 'Legacy (P2PKH)'; + static readonly type = 'legacy'; + static readonly defaultTypeReadable = 'Legacy (P2PKH)'; + // @ts-ignore: override + public readonly type = LegacyWallet.type; + // @ts-ignore: override + public readonly typeReadable: string; + + _txs_by_external_index: Record = {}; + _txs_by_internal_index: Record = {}; - _txs_by_external_index: Transaction[] = []; - _txs_by_internal_index: Transaction[] = []; + constructor(typeReadable?: string) { + super(); + + this.typeReadable = typeReadable ?? LegacyWallet.defaultTypeReadable; + } /** * Simple function which says that we havent tried to fetch balance @@ -32,10 +43,7 @@ export class LegacyWallet extends AbstractWallet { * @return {boolean} */ timeToRefreshBalance(): boolean { - if (+new Date() - this._lastBalanceFetch >= 5 * 60 * 1000) { - return true; - } - return false; + return +new Date() - this._lastBalanceFetch >= 5 * 60 * 1000; } /** @@ -45,8 +53,11 @@ export class LegacyWallet extends AbstractWallet { * @return {boolean} */ timeToRefreshTransaction(): boolean { + if (this._lastTxFetch >= +new Date() - 5 * 60 * 1000) { + return false; + } for (const tx of this.getTransactions()) { - if ((tx.confirmations ?? 0) < 7 && this._lastTxFetch < +new Date() - 5 * 60 * 1000) { + if ((tx.confirmations ?? 0) < 7) { return true; } } @@ -58,25 +69,13 @@ export class LegacyWallet extends AbstractWallet { this.secret = ECPair.makeRandom({ rng: () => buf }).toWIF(); } - async generateFromEntropy(user: Buffer): Promise { - let i = 0; - do { - i += 1; - const random = await randomBytes(user.length < 32 ? 32 - user.length : 0); - const buf = Buffer.concat([user, random], 32); - try { - this.secret = ECPair.fromPrivateKey(buf).toWIF(); - return; - } catch (e) { - if (i === 5) throw e; - } - } while (true); + async generateFromEntropy(user: Uint8Array): Promise { + if (user.length !== 32) { + throw new Error('Entropy should be 32 bytes'); + } + this.secret = ECPair.fromPrivateKey(user).toWIF(); } - /** - * - * @returns {string} - */ getAddress(): string | false { if (this._address) return this._address; let address; @@ -131,26 +130,25 @@ export class LegacyWallet extends AbstractWallet { const address = this.getAddress(); if (!address) throw new Error('LegacyWallet: Invalid address'); const utxos = await BlueElectrum.multiGetUtxoByAddress([address]); - this.utxo = []; + this._utxo = []; for (const arr of Object.values(utxos)) { - this.utxo = this.utxo.concat(arr); + this._utxo = this._utxo.concat(arr); } // now we need to fetch txhash for each input as required by PSBT if (LegacyWallet.type !== this.type) return; // but only for LEGACY single-address wallets const txhexes = await BlueElectrum.multiGetTransactionByTxid( - this.utxo.map(u => u.txId), - 50, + this._utxo.map(u => u.txid), false, ); const newUtxos = []; - for (const u of this.utxo) { - if (txhexes[u.txId]) u.txhex = txhexes[u.txId]; + for (const u of this._utxo) { + if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; newUtxos.push(u); } - this.utxo = newUtxos; + this._utxo = newUtxos; } catch (error) { console.warn(error); } @@ -161,10 +159,8 @@ export class LegacyWallet extends AbstractWallet { * [ { height: 0, * value: 666, * address: 'string', - * txId: 'string', * vout: 1, * txid: 'string', - * amount: 666, * wif: 'string', * confirmations: 0 } ] * @@ -173,8 +169,7 @@ export class LegacyWallet extends AbstractWallet { */ getUtxo(respectFrozen = false): Utxo[] { let ret: Utxo[] = []; - for (const u of this.utxo) { - if (u.txId) u.txid = u.txId; + for (const u of this._utxo) { if (!u.confirmations && u.height) u.confirmations = BlueElectrum.estimateCurrentBlockheight() - u.height; ret.push(u); } @@ -211,11 +206,9 @@ export class LegacyWallet extends AbstractWallet { const value = new BigNumber(output.value).multipliedBy(100000000).toNumber(); utxos.push({ txid: tx.txid, - txId: tx.txid, vout: output.n, address, value, - amount: value, confirmations: tx.confirmations, wif: false, height: BlueElectrum.estimateCurrentBlockheight() - (tx.confirmations ?? 0), @@ -228,9 +221,10 @@ export class LegacyWallet extends AbstractWallet { // got all utxos we ever had. lets filter out the ones that are spent: const ret = []; + const txs = this.getTransactions(); for (const utxo of utxos) { let spent = false; - for (const tx of this.getTransactions()) { + for (const tx of txs) { for (const input of tx.inputs) { if (input.txid === utxo.txid && input.vout === utxo.vout) spent = true; // utxo we got previously was actually spent right here ^^ @@ -280,7 +274,7 @@ export class LegacyWallet extends AbstractWallet { // is safe because in that case our cache is filled // next, batch fetching each txid we got - const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs)); + const txdatas = await BlueElectrum.multiGetTransactionByTxid(Object.keys(txs), true); const transactions = Object.values(txdatas); // now, tricky part. we collect all transactions from inputs (vin), and batch fetch them too. @@ -288,10 +282,11 @@ export class LegacyWallet extends AbstractWallet { const vinTxids = []; for (const txdata of transactions) { for (const vin of txdata.vin) { - vinTxids.push(vin.txid); + vin.txid && vinTxids.push(vin.txid); + // ^^^^ not all inputs have txid, some of them are Coinbase (newly-created coins) } } - const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids); + const vintxdatas = await BlueElectrum.multiGetTransactionByTxid(vinTxids, true); // fetched all transactions from our inputs. now we need to combine it. // iterating all _our_ transactions: @@ -327,6 +322,7 @@ export class LegacyWallet extends AbstractWallet { ...txRest, inputs: [...vin2], outputs: [...vout], + timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */, }; _txsByExternalIndex.push(clonedTx); @@ -340,6 +336,7 @@ export class LegacyWallet extends AbstractWallet { ...txRest, inputs: [...vin], outputs: [...vout2], + timestamp: tx.blocktime || tx.time || Math.floor(+new Date() / 1000) - 30 /* unconfirmed */, }; _txsByExternalIndex.push(clonedTx); @@ -347,15 +344,18 @@ export class LegacyWallet extends AbstractWallet { } } - this._txs_by_external_index = _txsByExternalIndex; + this._txs_by_external_index = { 0: _txsByExternalIndex }; this._lastTxFetch = +new Date(); } getTransactions(): Transaction[] { // a hacky code reuse from electrum HD wallet: - this._txs_by_external_index = this._txs_by_external_index || []; - this._txs_by_internal_index = []; + this._txs_by_external_index = this._txs_by_external_index || {}; + this._txs_by_internal_index = {}; + const { HDSegwitBech32Wallet } = require('./hd-segwit-bech32-wallet') as { + HDSegwitBech32Wallet: typeof HDSegwitBech32WalletT; + }; const hd = new HDSegwitBech32Wallet(); return hd.getTransactions.apply(this); } @@ -374,24 +374,57 @@ export class LegacyWallet extends AbstractWallet { } coinselect( - utxos: CoinSelectUtxo[], - targets: CoinSelectTarget[], + utxos: CreateTransactionUtxo[], + targets: CreateTransactionTarget[], feeRate: number, - changeAddress: string, ): { inputs: CoinSelectReturnInput[]; outputs: CoinSelectOutput[]; fee: number; } { - if (!changeAddress) throw new Error('No change address provided'); - let algo = coinSelect; // if targets has output without a value, we want send MAX to it if (targets.some(i => !('value' in i))) { algo = coinSelectSplit; } - const { inputs, outputs, fee } = algo(utxos, targets, feeRate); + const _utxos = JSON.parse(JSON.stringify(utxos)) as CreateTransactionUtxo[]; + const _targets = JSON.parse(JSON.stringify(targets)) as CreateTransactionTarget[]; + + // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation + for (const u of _utxos) { + if (u.script?.length) { + continue; + } + + // counting the number of vbytes for each script type: + if (this.segwitType === 'p2wpkh') { + // 72 (high R low S signature) + 1 + 33 (comp pubkey) + 1 = 107 / 4 = 26.75 rounded up. + u.script = { length: 27 }; + } else if (this.segwitType === 'p2sh(p2wpkh)') { + // ((72 (high R low S signature) + 1 + 33 (comp pubkey) + 1) / 4) + 22 (P2WPKH output on scriptSig stack) + 1 = 49.75 rounded up + u.script = { length: 50 }; + } else if (this.segwitType === 'p2tr') { + // taproot key path spend is just a 64 or 65 byte signature on the witness stack. + // So it would be 65 bytes (assuming max size) + the pushbyte for 65 bytes on the stack, which makes 66. + // 66 / 4 = 16.5 round up to 17 + u.script = { length: 17 }; + } + } + + for (const t of _targets) { + if (t.address?.startsWith('bc1')) { + // in case address is non-typical and takes more bytes than coinselect library anticipates by default + t.script = { length: bitcoin.address.toOutputScript(t.address).length + 3 }; + } + + if (t.script?.hex) { + // setting length for coinselect lib manually as it is not aware of our field `hex` + t.script.length = t.script.hex.length / 2 - 4; + } + } + + const { inputs, outputs, fee } = algo(_utxos, _targets as CoinSelectTarget[], feeRate); // .inputs and .outputs will be undefined if no solution was found if (!inputs || !outputs) { @@ -403,7 +436,7 @@ export class LegacyWallet extends AbstractWallet { /** * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String, txhex: String, }>} List of spendable utxos * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) * @param feeRate {Number} satoshi per byte * @param changeAddress {String} Excessive coins will go back to that address @@ -422,19 +455,18 @@ export class LegacyWallet extends AbstractWallet { masterFingerprint: number, ): CreateTransactionResult { if (targets.length === 0) throw new Error('No destination provided'); - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); sequence = sequence || 0xffffffff; // disable RBF by default const psbt = new bitcoin.Psbt(); let c = 0; - const values: Record = {}; let keyPair: Signer | null = null; + if (!skipSigning) { + // skiping signing related stuff + keyPair = ECPair.fromWIF(this.secret); // secret is WIF + } + inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } - values[c] = input.value; c++; if (!input.txhex) throw new Error('UTXO is missing txhex of the input, which is required by PSBT for non-segwit input'); @@ -444,7 +476,7 @@ export class LegacyWallet extends AbstractWallet { index: input.vout, sequence, // non-segwit inputs now require passing the whole previous tx as Buffer - nonWitnessUtxo: Buffer.from(input.txhex, 'hex'), + nonWitnessUtxo: hexToUint8Array(input.txhex), }); }); @@ -457,7 +489,7 @@ export class LegacyWallet extends AbstractWallet { sanitizedOutputs.forEach(output => { const outputData = { address: output.address, - value: output.value, + value: BigInt(output.value), }; psbt.addOutput(outputData); @@ -478,12 +510,13 @@ export class LegacyWallet extends AbstractWallet { } getLatestTransactionTime(): string | 0 { - if (this.getTransactions().length === 0) { + const transactions = this.getTransactions(); + if (transactions.length === 0) { return 0; } let max = 0; - for (const tx of this.getTransactions()) { - max = Math.max(new Date(tx.received ?? 0).getTime(), max); + for (const tx of transactions) { + max = Math.max(tx.timestamp ? tx.timestamp * 1000 : 0, max); } return new Date(max).toString(); } @@ -507,7 +540,7 @@ export class LegacyWallet extends AbstractWallet { const decoded = bitcoin.address.fromBech32(address); if (decoded.version === 0) return true; if (decoded.version === 1 && decoded.data.length !== 32) return false; - if (decoded.version === 1 && !ecc.isPoint(Buffer.concat([Buffer.from([2]), decoded.data]))) return false; + if (decoded.version === 1 && !ecc.isPoint(concatUint8Arrays([new Uint8Array([2]), decoded.data]))) return false; if (decoded.version > 1) return false; // ^^^ some day, when versions above 1 will be actually utilized, we would need to unhardcode this return true; @@ -524,7 +557,7 @@ export class LegacyWallet extends AbstractWallet { */ static scriptPubKeyToAddress(scriptPubKey: string): string | false { try { - const scriptPubKey2 = Buffer.from(scriptPubKey, 'hex'); + const scriptPubKey2 = hexToUint8Array(scriptPubKey); return ( bitcoin.payments.p2pkh({ output: scriptPubKey2, @@ -593,8 +626,17 @@ export class LegacyWallet extends AbstractWallet { const keyPair = ECPair.fromWIF(wif); const privateKey = keyPair.privateKey; if (!privateKey) throw new Error('Invalid private key'); - const options = this.segwitType && useSegwit ? { segwitType: this.segwitType } : undefined; - const signature = bitcoinMessage.sign(message, privateKey, keyPair.compressed, options); + let segwitType: 'p2wpkh' | 'p2sh(p2wpkh)'; + switch (this.segwitType) { + case 'p2sh(p2wpkh)': + segwitType = 'p2sh(p2wpkh)'; + break; + default: + segwitType = 'p2wpkh'; + break; + } + const options = this.segwitType && useSegwit ? { segwitType } : undefined; + const signature = bitcoinMessage.sign(message, Buffer.from(privateKey), keyPair.compressed, options); return signature.toString('base64'); } diff --git a/class/wallets/lightning-ark-wallet.ts b/class/wallets/lightning-ark-wallet.ts new file mode 100644 index 00000000000..829156948f1 --- /dev/null +++ b/class/wallets/lightning-ark-wallet.ts @@ -0,0 +1,942 @@ +import BigNumber from 'bignumber.js'; +import { sha256 } from '@noble/hashes/sha256'; +import { + ArkadeSwaps, + BoltzSubmarineSwap, + BoltzSwap, + BoltzSwapProvider, + SubmarineRefundOutcome, + decodeInvoice, + isChainSwapClaimable, + isChainSwapRefundable, + isReverseClaimableStatus, + isReverseSwapClaimable, + isSubmarineSwapRefundable, +} from '@arkade-os/boltz-swap'; +import { RealmSwapRepository } from '@arkade-os/boltz-swap/repositories/realm'; +import { RestDelegatorProvider, SingleKey, Wallet, ExtendedCoin, ArkTransaction, TxType } from '@arkade-os/sdk'; +import { ExpoArkProvider, ExpoIndexerProvider } from '@arkade-os/sdk/adapters/expo'; +import { RealmContractRepository, RealmWalletRepository } from '@arkade-os/sdk/repositories/realm'; + +import BIP32Factory from 'bip32'; + +import { LightningCustodianWallet } from './lightning-custodian-wallet.ts'; +import { randomBytes } from '../rng.ts'; +import * as bip39 from 'bip39'; +import { LightningTransaction, Transaction } from './types.ts'; +import { hexToUint8Array, uint8ArrayToHex } from '../../blue_modules/uint8array-extras/index'; +import assert from 'assert'; +import ecc from '../../blue_modules/noble_ecc.ts'; +import { Measure } from '../measure.ts'; +import { deleteArkadeRealm, getArkadeRealm } from '../../blue_modules/arkade-adapters/realm/realmInstance'; +import { registerArkPaymentPush } from '../../blue_modules/notifications'; +const { bech32m } = require('bech32'); + +const bip32 = BIP32Factory(ecc); + +// Delegate-service URL per Ark network. Mirrors the canonical wallet's map +// (../master/wallet/src/lib/constants.ts:27): mainnet has a delegator, +// mutinynet/regtest each have their own, and signet/testnet have none — for +// those we must skip `delegatorProvider` on Wallet.create entirely instead of +// falling back to the mainnet URL, which would build the wrong offchain +// tapscript and hide funds from the indexer. +const DELEGATOR_URLS = { + bitcoin: 'https://delegate.arkade.money', + mutinynet: 'https://delegator.mutinynet.arkade.sh', + regtest: 'http://localhost:7012', + signet: null, + testnet: null, +} as const; + +const staticWalletCache: Record = {}; +const staticSwapsCache: Record = {}; +const initInFlight: Map> = new Map(); +const boardingLock: Record = {}; +// Coalesce concurrent restoreSwaps() calls per namespace so a manual tap +// during init (or two screens triggering it together) does not double-fetch +// from Boltz. +const restoreInFlight: Map> = new Map(); + +// Test-only: exposes module-private caches so unit tests can observe / reset +// them and verify deletion-vs-init race behavior. Not part of the public API. +export const __testing__ = { + staticWalletCache, + staticSwapsCache, + initInFlight, + boardingLock, + restoreInFlight, +}; + +export class LightningArkWallet extends LightningCustodianWallet { + static readonly type = 'lightningArkWallet'; + static readonly typeReadable = 'Lightning Arkade'; + static readonly subtitleReadable = 'Arkade'; + // @ts-ignore: override + public readonly type = LightningArkWallet.type; + // @ts-ignore: override + public readonly typeReadable = LightningArkWallet.typeReadable; + + // Runtime SDK objects. The constructor re-defines these as non-enumerable so + // saveToDisk's `Object.assign({}, key)` skips them and JSON.stringify never + // sees a partially-initialized SDK snapshot. We avoid the `declare` modifier + // here because @babel/preset-typescript in the React Native pipeline requires + // `allowDeclareFields: true` for it, and tightening that setting is out of + // scope. + private _wallet: Wallet | undefined; + private _arkadeSwaps: ArkadeSwaps | undefined; + // sha256(secret) is cheap but getNamespace is called on every init, delete, + // boarding poll, and background-task pass. Memoize keyed by `secret` so a + // future setSecret() with a different mnemonic self-invalidates without us + // having to override the inherited setter. Defined non-enumerable in the + // constructor for the same saveToDisk serialization reason as the SDK refs. + private _namespaceCache: { secret: string; namespace: string } | undefined; + + private _arkServerUrl: string = 'https://arkade.computer'; + // Network this wallet speaks. Drives the delegator URL lookup below; today + // the Ark server URL is fixed to mainnet, so this is always 'bitcoin', but + // the indirection keeps a future testnet/mutinynet/regtest switch from + // silently shipping the mainnet delegator URL to the wrong network. + private _network: keyof typeof DELEGATOR_URLS = 'bitcoin'; + + private _swapHistory: BoltzSwap[] = []; + private _transactionsHistory: ArkTransaction[] = []; + private _privateKeyCache = ''; + private _boardingUtxos: ExtendedCoin[] = []; + + // limits/fees from Boltz reverse-swap (Lightning → Arkade) bracket: + private _limitMin: number = 0; + private _limitMax: number = 0; + private _feePercentage: number = 0; + + // Submarine-swap (Arkade → Lightning) fee bracket — the PAY side, surfaced on + // the Lightning pay screen. Distinct from `_feePercentage` above (reverse leg). + private _submarineFeePercentage: number = 0; + private _submarineMinerFees: number = 0; + // Runtime "fetched fees this session" flag. Redefined non-enumerable in the + // constructor so saveToDisk never persists it — see the constructor for why. + private _feesLoaded: boolean = false; + + constructor() { + super(); + Object.defineProperty(this, '_wallet', { value: undefined, writable: true, enumerable: false, configurable: true }); + Object.defineProperty(this, '_arkadeSwaps', { value: undefined, writable: true, enumerable: false, configurable: true }); + Object.defineProperty(this, '_namespaceCache', { value: undefined, writable: true, enumerable: false, configurable: true }); + // Non-enumerable so saveToDisk's Object.assign({}, wallet) does not persist + // it. Persisting `true` would make a restored wallet skip the per-session + // Boltz fee refresh in ensureLightningFeesLoaded() (init() also skips it + // because _limitMin/_limitMax are serialized truthy), pinning a stale fee + // estimate on the pay screen across restarts. Resetting to false each + // process forces exactly one refresh per session. The fee *values* stay + // enumerable (cached like the reverse-leg fields) but are gated behind this + // flag — getSubmarineFeeEstimate() returns undefined until it flips true. + Object.defineProperty(this, '_feesLoaded', { value: false, writable: true, enumerable: false, configurable: true }); + } + + hashIt = (s: string): string => { + return uint8ArrayToHex(sha256(s)); + }; + + _getIdentity() { + assert(this.secret, 'No secret provided'); + + if (!this._privateKeyCache) { + const mnemonic = this.secret.replace('arkade://', '').trim(); + const seed = bip39.mnemonicToSeedSync(mnemonic); + + const index = 0; + const internal = 0; + const accountNumber = 0; + const root = bip32.fromSeed(seed); + const path = `m/86'/0'/${accountNumber}'/${internal}/${index}`; + const child = root.derivePath(path); + assert(child.privateKey, 'Internal error: no private key for child'); + + this._privateKeyCache = uint8ArrayToHex(child.privateKey); + } + + return SingleKey.fromPrivateKey(hexToUint8Array(this._privateKeyCache)); + } + + getNamespace(): string { + assert(this.secret, 'No secret provided'); + if (this._namespaceCache?.secret === this.secret) return this._namespaceCache.namespace; + const namespace = this.hashIt(this.secret); + this._namespaceCache = { secret: this.secret, namespace }; + return namespace; + } + + async init() { + const namespace = this.getNamespace(); + + if (this._wallet && this._arkadeSwaps) return; + + const cachedWallet = staticWalletCache[namespace]; + const cachedSwaps = staticSwapsCache[namespace]; + if (cachedWallet && cachedSwaps) { + this._wallet = cachedWallet; + this._arkadeSwaps = cachedSwaps; + if (!this._limitMin || !this._limitMax) await this._fetchLightningFeesAndLimits(); + return; + } + + let inFlight = initInFlight.get(namespace); + if (!inFlight) { + inFlight = (async () => { + const realm = await getArkadeRealm(namespace); + const walletRepository = new RealmWalletRepository(realm as any); + const contractRepository = new RealmContractRepository(realm as any); + const swapRepository = new RealmSwapRepository(realm as any); + + // Resolve the delegator URL up front and preflight it. A mismatched + // URL silently builds the wrong offchain tapscript, and a flaky + // delegator turns into a generic mid-init Wallet.create rejection. + // Networks with no delegator (signet/testnet) skip the provider + // entirely. + const delegatorUrl = DELEGATOR_URLS[this._network]; + let delegatorProvider: RestDelegatorProvider | undefined; + if (delegatorUrl !== null) { + delegatorProvider = new RestDelegatorProvider(delegatorUrl); + try { + await delegatorProvider.getDelegateInfo(); + } catch (e: any) { + throw new Error(`Delegate service unreachable (${delegatorUrl}): ${e?.message ?? e}`); + } + } + + const mm = new Measure('Wallet.create()'); + const wallet = await Wallet.create({ + identity: this._getIdentity(), + arkProvider: new ExpoArkProvider(this._arkServerUrl), + indexerProvider: new ExpoIndexerProvider(this._arkServerUrl), + storage: { walletRepository, contractRepository }, + delegatorProvider, + }); + staticWalletCache[namespace] = wallet; + mm.end(); + + // apiUrl omitted: @arkade-os/boltz-swap defaults to the production + // mainnet URL when network is 'bitcoin'. + const swapProvider = new BoltzSwapProvider({ network: 'bitcoin', referralId: 'arkade-blue-wallet' }); + + const arkadeSwaps = new ArkadeSwaps({ + wallet, + swapProvider, + swapRepository, + }); + staticSwapsCache[namespace] = arkadeSwaps; + + // Push refresh on swap lifecycle events so balance and history + // reflect SwapManager's autonomous claim/refund actions without + // waiting for the next user-driven fetchBalance tick. + this._subscribeToSwapEvents(arkadeSwaps); + + return { wallet, arkadeSwaps }; + })(); + + initInFlight.set(namespace, inFlight); + inFlight + .finally(() => { + if (initInFlight.get(namespace) === inFlight) initInFlight.delete(namespace); + }) + .catch(() => { + // The same rejection is delivered to `await inFlight` callers below; silence here so + // the discarded cleanup chain does not become an unhandled rejection. + }); + } + + const { wallet, arkadeSwaps } = await inFlight; + this._wallet = wallet; + this._arkadeSwaps = arkadeSwaps; + + if (!this._limitMin || !this._limitMax) await this._fetchLightningFeesAndLimits(); + } + + private _subscribeToSwapEvents(arkadeSwaps: ArkadeSwaps) { + const swapManager = arkadeSwaps.getSwapManager(); + if (!swapManager) return; + + const refresh = async () => { + try { + if (this._arkadeSwaps !== arkadeSwaps) return; // stale subscription after onDelete + this._swapHistory = await arkadeSwaps.getSwapHistory(); + if (this._wallet) { + this._transactionsHistory = await this._wallet.getTransactionHistory(); + const balance = await this._wallet.getBalance(); + // Keep this in sync with fetchBalance(): offchain spendable + recoverable, + // boarding excluded (see fetchBalance for the double-count rationale). + this.balance = balance.available + balance.recoverable; + } + this._lastBalanceFetch = +new Date(); + this._lastTxFetch = +new Date(); + } catch (e: any) { + console.log('[ARK] swap-event refresh failed:', e?.message ?? e); + } + }; + + swapManager.onSwapCompleted(refresh).catch(() => {}); + swapManager.onSwapFailed(refresh).catch(() => {}); + swapManager.onActionExecuted(refresh).catch(() => {}); + } + + private async _fetchLightningFeesAndLimits() { + assert(this._arkadeSwaps, 'ArkadeSwaps must be initialized first'); + try { + const [fees, limits] = await Promise.all([this._arkadeSwaps.getFees(), this._arkadeSwaps.getLimits()]); + this._feePercentage = fees.reverse?.percentage ?? 0; + this._submarineFeePercentage = fees.submarine?.percentage ?? 0; + this._submarineMinerFees = fees.submarine?.minerFees ?? 0; + this._limitMin = limits.min ?? 333; + this._limitMax = limits.max ?? 1_000_000; + this._feesLoaded = true; + if (!fees.reverse?.percentage) { + console.log('warning: unexpected fees response from boltz:', JSON.stringify(fees, null, 2)); + } + } catch (e: any) { + console.log('[ARK] Failed to fetch Boltz fees/limits:', e?.message ?? e); + } + } + + async generate(): Promise { + const buf = await randomBytes(16); + this.secret = 'arkade://' + bip39.entropyToMnemonic(uint8ArrayToHex(buf)); + + await this.init(); + } + + getSecret() { + return this.secret; + } + + /** + * Single source of activity for the BlueWallet transaction list. The SDK's + * `getTransactionHistory()` (`_transactionsHistory`) is the source of truth + * for every settled row; swaps annotate those rows rather than producing + * parallel ones. Three passes build the result: + * + * 1. `_transactionsHistory` (Ark SDK) — the base rows: + * - `key.boardingTxid` is set ONLY on boarding outputs, so it is an + * exclusive refill discriminator: RECEIVED && settled → "Refill" + * (`boarding-`); other boarding states are suppressed (pending + * boarding is surfaced from `_boardingUtxos` in pass 2). + * - no `boardingTxid` → native Ark leg (`ark-`), + * SENT negative / RECEIVED positive. Labeled "Lightning" by + * TransactionListItem; a settled swap may enrich it in place (pass 3). + * + * 2. `_boardingUtxos` → "Pending refill" rows (boarding UTXO not yet swept), + * with a live timestamp. + * + * 3. `_swapHistory` (Boltz) — annotation + residual. A *settled* swap (reverse + * `invoice.settled`, submarine `transaction.claimed`) always has an + * Ark-side leg in pass 1 — reverse settles by Boltz claiming into our + * address, submarine by our lockup being claimed. We find the unconsumed + * native leg matching `(direction, on-chain amount)` within ±30 min, mark + * it consumed, and enrich it (memo, invoice, preimage, payment_hash, + * ispaid) and emit NO row for the swap itself. This is what eliminates the + * duplicate-row class: a settlement can no longer appear once as its native + * leg and once as a `swap-` row, regardless of timestamp skew. A settled + * swap with no matching leg emits nothing — the leg is present in virtually + * all cases (settling *is* creating it), and a fallback `swap-` row would + * re-introduce the duplicate. Non-settled swaps that still need visibility + * (claimable reverse, in-flight submarine, failed/refunded, and open + * invoices when `includeUnpaidInvoices`) are emitted as `swap-` rows. + * Settlement signal is `LightningTransaction.ispaid`; we never invent a + * `confirmations` field for an LN/Ark row. + * + * Stable row ids survive status transitions: `boarding-`, + * `boarding-utxo-:`, `ark-`, `swap-`. + * + * Hidden states: + * - Submarine `invoice.set` → dropped (no funds at risk yet). + * - Submarine `swap.expired` / `invoice.expired` → kept with a `Failed: ` + * prefix; SDK classifies them as refundable so the user needs the row + * to recover an on-chain lockup. + * - Unpaid reverse invoices with no payment in flight (`type === 'reverse'` + * AND `!ispaid` AND `!memoPrefix` AND NOT isReverseClaimableStatus) are + * dropped. This covers `swap.created` (invoice generated, never paid) and + * `invoice.expired` / `swap.expired` (unpaid & dead). Only claimable + * reverse swaps (`transaction.mempool` / `transaction.confirmed`, funds + * locked on-chain) survive as genuine pending receives. The guard is gated + * to (a) reverse only — submarine pending rows may have on-chain locked + * funds that need recovery visibility — and (b) non-terminal rows so a + * `Failed: ` / `Refunded: ` row is still preserved for diagnosis. + * This drop is display-only: `getUserInvoices()` and + * `isInvoiceGeneratedByWallet()` call with `includeUnpaidInvoices=true` so a + * just-created, unpaid invoice stays discoverable by the receive-screen poll + * and the clipboard heuristic, even though it is hidden from the history list. + * - Failed/refunded swaps stay visible with `ispaid:false` and a + * `Failed: ` / `Refunded: ` memo prefix so support can diagnose them. + */ + getTransactions(includeUnpaidInvoices = false): (Transaction & LightningTransaction)[] { + const walletID = this.getID(); + const ret: any[] = []; + const MATCH_WINDOW_SEC = 30 * 60; + + // Pass 1 — base rows from the SDK transaction history (single source of + // truth). `key.boardingTxid` is set only on boarding outputs, so it is an + // exclusive refill discriminator; every other entry is a native Ark leg a + // settled swap may later enrich in place. + type NativeLeg = { row: any; arkType: TxType; absAmount: number; matched: boolean }; + const nativeLegs: NativeLeg[] = []; + + // Boarding txids already surfaced as a settled "Refill" (pass 1). A boarding + // UTXO leaves getBoardingUtxos() and its settled history entry appears on the + // same on-chain signal (the boarding output being spent), so the two feeds + // normally flip together — but if they disagree for a beat we must not show + // one refill as BOTH a settled "Refill" and a "Pending refill" row. Pass 2 + // dedupes against this set. + const settledRefillTxids = new Set(); + + for (const histTx of this._transactionsHistory) { + if (histTx.key.boardingTxid) { + // Settled refill only; pending boarding comes from _boardingUtxos (pass 2). + if (histTx.type === TxType.TxReceived && histTx.settled) { + settledRefillTxids.add(histTx.key.boardingTxid); + ret.push({ + txid: `boarding-${histTx.key.boardingTxid}`, + type: 'bitcoind_tx', + walletID, + description: 'Refill', + memo: 'Refill', + value: histTx.amount, + timestamp: Math.floor(histTx.createdAt / 1000), + }); + } + continue; + } + + const absAmount = Math.abs(histTx.amount); + const createdAtSec = Math.floor(histTx.createdAt / 1000); + const direction = histTx.type === TxType.TxSent ? -1 : 1; + const idKey = histTx.key.arkTxid || histTx.key.commitmentTxid || `${histTx.type}-${createdAtSec}-${absAmount}`; + const description = histTx.type === TxType.TxSent ? 'Sent' : 'Received'; + const row: any = { + txid: `ark-${idKey}`, + type: 'bitcoind_tx', + walletID, + description, + memo: description, + value: absAmount * direction, + timestamp: createdAtSec, + }; + ret.push(row); + nativeLegs.push({ row, arkType: histTx.type, absAmount, matched: false }); + } + + // Pass 2 — pending refills (boarding UTXOs not yet swept), live timestamp. + // These render as "Pending" (TransactionListItem) until settlement promotes + // them into a settled "Refill" row (pass 1) and into the spendable balance. + for (const boardingTx of this._boardingUtxos) { + // Already shown as a settled "Refill" in pass 1 — don't also list it pending. + if (settledRefillTxids.has(boardingTx.txid)) continue; + ret.push({ + txid: `boarding-utxo-${boardingTx.txid}:${boardingTx.vout}`, + type: 'bitcoind_tx', + walletID, + description: 'Pending refill', + memo: 'Pending refill', + value: boardingTx.value, + timestamp: boardingTx.status.block_time ?? Math.floor(Date.now() / 1000), + }); + } + + // Pass 3 — swaps annotate a matching settled leg, or emit a residual row. + for (const swap of this._swapHistory) { + let memo = ''; + let value = 0; + let bolt11invoice = ''; + let payment_hash = ''; + let expiry: number | undefined; + const timestamp = swap.createdAt; + + try { + // @ts-ignore: present on reverse and submarine variants + bolt11invoice = swap.request.invoice || swap.response.invoice || ''; + if (bolt11invoice) { + const invoiceDetails = this.decodeInvoice(bolt11invoice); + value = invoiceDetails.num_satoshis; + memo = invoiceDetails.description; + payment_hash = invoiceDetails.payment_hash; + expiry = invoiceDetails.expiry; + } + } catch {} + + let direction: -1 | 1; + let ispaid = false; + let type: 'user_invoice' | 'payment_request' | 'paid_invoice'; + let memoPrefix = ''; + + if (swap.type === 'reverse') { + direction = 1; + type = 'user_invoice'; + // The SDK hardcodes "Send to Arkade address" as the default reverse-swap + // invoice description, so matching that + // exact literal is safe. A user-supplied description + // is kept as-is. + if (!memo || memo === 'Send to Arkade address') { + memo = 'Received via Arkade'; + } + switch (swap.status) { + case 'invoice.settled': + ispaid = true; + break; + case 'transaction.failed': + case 'transaction.lockupFailed': + case 'transaction.refunded': + memoPrefix = 'Failed: '; + break; + // transaction.mempool / transaction.confirmed → payment in flight, + // genuine pending receive (isReverseClaimableStatus is true here) + // swap.created → invoice made but nobody has paid; invoice.expired / + // swap.expired → unpaid & dead. Both are dropped by the + // unpaid-not-in-flight filter below — neither is "pending". + } + } else if (swap.type === 'submarine') { + direction = -1; + switch (swap.status) { + case 'transaction.claimed': + ispaid = true; + type = 'paid_invoice'; + break; + case 'invoice.set': + // No funds at risk yet — user hasn't broadcast the lockup. + continue; + case 'transaction.refunded': + memoPrefix = 'Refunded: '; + type = 'payment_request'; + break; + case 'invoice.failedToPay': + case 'transaction.failed': + case 'transaction.lockupFailed': + case 'swap.expired': + case 'invoice.expired': + // SDK classifies swap.expired as a refundable submarine failure + // (lockup is still on-chain). Keep the row visible so users can + // recover funds. invoice.expired is not reachable per the SDK + // lifecycle today; treated as failed for safety. + memoPrefix = 'Failed: '; + type = 'payment_request'; + break; + default: + // swap.created / invoice.pending / invoice.paid → pending send + type = 'payment_request'; + } + } else { + // 'chain' — no LN-shaped UX surface yet. + continue; + } + + // Resolve effective amount: prefer the on-chain (Ark) leg, fall back to + // the invoice amount, then to the swap-request invoiceAmount. + // @ts-ignore properties exist on the variant union + const rawValue = swap.response.onchainAmount || swap.response.expectedAmount || value || swap.request.invoiceAmount || 0; + const absValue = Math.abs(rawValue); + value = absValue * direction; + + // Settled swaps (reverse `invoice.settled` / submarine `transaction.claimed`) + // are represented by their Ark-side leg from pass 1 — reverse settles by + // Boltz claiming into our address, submarine by our lockup being claimed, + // so the leg is in `getTransactionHistory()` in virtually all cases. We + // enrich that leg in place (memo/invoice/preimage) and NEVER emit a + // separate row for a settled swap: emitting no row here is the structural + // guarantee that a settlement cannot appear twice (once as its native leg, + // once as a `swap-` row), regardless of any timestamp skew between the + // swap and its leg. Match on (direction, on-chain amount) within ±30 min + // and consume each leg once. A leg that is briefly missing (sync lag) + // reappears — enriched — on the next fetch; the only cost of a window/ + // amount miss is a generic memo on a row that already reads "Lightning". + if (ispaid) { + const arkType = direction < 0 ? TxType.TxSent : TxType.TxReceived; + const leg = nativeLegs.find( + l => !l.matched && l.arkType === arkType && l.absAmount === absValue && Math.abs(l.row.timestamp - timestamp) <= MATCH_WINDOW_SEC, + ); + if (leg) { + leg.matched = true; + leg.row.description = memoPrefix + memo; + leg.row.memo = memoPrefix + memo; + leg.row.ispaid = true; + leg.row.payment_hash = payment_hash; + leg.row.payment_request = bolt11invoice; + // @ts-ignore preimage is required for reverse, optional for submarine + leg.row.payment_preimage = swap.preimage; + } + continue; + } + + // Non-settled: hide unpaid reverse invoices with no payment in flight + // (`swap.created`, `invoice.expired` / `swap.expired`). Claimable reverse + // swaps and terminal `Failed: ` / `Refunded: ` rows survive; submarine rows + // of any status survive (lockup may be on-chain and recoverable). + // Display-only drop — registry callers pass includeUnpaidInvoices=true. + if (!includeUnpaidInvoices && swap.type === 'reverse' && !memoPrefix && !isReverseClaimableStatus(swap.status)) continue; + + ret.push({ + txid: `swap-${swap.id}`, + type, + walletID, + description: memoPrefix + memo, + memo: memoPrefix + memo, + value, + timestamp, + ispaid, + // A non-empty memoPrefix is set only for terminal failed/refunded/expired + // swaps (see status switches above). Surfacing it explicitly lets the UI + // tell "in flight" (`ispaid:false`, no prefix) apart from "dead" + // (`ispaid:false`, prefix set) without string-matching the memo. + failed: memoPrefix !== '', + payment_hash, + payment_request: bolt11invoice, + amt: value, + // @ts-ignore preimage is required for reverse, optional for submarine + payment_preimage: swap.preimage, + expire_time: expiry ?? 3600, + }); + } + + return ret; + } + + async fetchUserInvoices() { + // nop + } + + async fetchTransactions() { + if (!this._wallet) await this.init(); + if (!this._wallet) throw new Error('Arkade wallet not initialized'); + if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized'); + + this._swapHistory = await this._arkadeSwaps.getSwapHistory(); + this._transactionsHistory = await this._wallet.getTransactionHistory(); + this._lastTxFetch = +new Date(); + } + + async fetchBalance(): Promise { + if (!this._wallet) await this.init(); + if (!this._wallet) throw new Error('Arkade wallet not initialized'); + + await this._attemptBoardUtxos(); + + const balance = await this._wallet.getBalance(); + this._lastBalanceFetch = +new Date(); + // Headline balance = spendable offchain + recoverable, i.e. SDK `total` + // minus `boarding.total`. A refill stays OUT of the balance until the SDK + // settles its boarding UTXO into a VTXO — the same moment its history row + // flips from "Pending" to a confirmed "Refill". Two reasons boarding is + // excluded here: + // 1. A pending/unconfirmed refill must not inflate the balance before it + // is usable; it is surfaced as a "Pending" row instead (getTransactions). + // 2. While settling, the SDK briefly reports BOTH the boarding UTXO (still + // unspent in getCoins) AND the freshly-minted preconfirmed VTXO, so + // `balance.total` transiently double-counts the refill. Counting only + // offchain+recoverable means each sat is counted once, at settlement. + // Mirrors the reference wallets (trixie's headline is `available`). + this.balance = balance.available + balance.recoverable; + } + + async payInvoice(invoice: string, freeAmount: number = 0) { + if (!this._wallet) await this.init(); + if (!this._wallet) throw new Error('Arkade wallet not initialized'); + + if (this.isAddressValid(invoice)) { + // its an ark address, so we need to do native ark-to-ark transfer + await this._wallet.sendBitcoin({ + address: invoice, + amount: freeAmount, + }); + return; + } + + assert(this._arkadeSwaps, 'ArkadeSwaps not initialized'); + + const invoiceDetails = decodeInvoice(invoice); + + assert(invoiceDetails.amountSats > this._limitMin, `Minimum you can send is ${this._limitMin} sat`); + assert(invoiceDetails.amountSats < this._limitMax, `Maximum you can is ${this._limitMax} sat`); + + const paymentResult = await this._arkadeSwaps.sendLightningPayment({ invoice }); + + this.last_paid_invoice_result = { + payment_preimage: paymentResult.preimage, + payment_hash: invoiceDetails.paymentHash, + payment_request: invoice, + }; + } + + /** + * Estimated Boltz fee in sats for paying a Lightning invoice of `amountSats` + * via a submarine swap (Arkade → Lightning): percentage fee + flat miner fee. + * Returns `undefined` until fees have been fetched — call + * `ensureLightningFeesLoaded()` first. This is the Boltz swap fee only; the + * Ark-network overhead is negligible and not included. + */ + getSubmarineFeeEstimate(amountSats: number): number | undefined { + if (!this._feesLoaded) return undefined; + const serviceFee = Math.ceil(new BigNumber(amountSats).multipliedBy(this._submarineFeePercentage).dividedBy(100).toNumber()); + return serviceFee + this._submarineMinerFees; + } + + /** Warm the cached Boltz fee/limit params so getSubmarineFeeEstimate() returns a value. */ + async ensureLightningFeesLoaded(): Promise { + if (this._feesLoaded) return; + await this.init(); // guarantees _arkadeSwaps is set (or throws) + // init() can return without fetching fees, so fetch explicitly if still cold. + if (!this._feesLoaded) await this._fetchLightningFeesAndLimits(); + } + + async getUserInvoices(): Promise { + await this.fetchTransactions(); + const txs = this.getTransactions(true); + return txs.filter(tx => tx.value! > 0); + } + + async addInvoice(amt: number, memo: string) { + if (!this._wallet) await this.init(); + assert(this._arkadeSwaps, 'ArkadeSwaps not initialized'); + assert(amt > this._limitMin, `Minimum to receive is ${this._limitMin} sat`); + assert(amt < this._limitMax, `Maximum to receive is ${this._limitMax} sat`); + + // fee percentage is smth like `0.01`, but its not 1%, its one-hundredth of a percent, rounded up + const serviceFee = Math.ceil(new BigNumber(amt).multipliedBy(this._feePercentage).dividedBy(100).toNumber()); + + const result = await this._arkadeSwaps.createLightningInvoice({ + amount: amt + serviceFee, + description: memo, + }); + + registerArkPaymentPush(result.paymentHash, memo, result.pendingSwap); // fire-and-forget, never throws + + return result.invoice; + } + + async getArkAddress(): Promise { + if (!this._wallet) await this.init(); + if (!this._wallet) throw new Error('Arkade not initialized'); + return await this._wallet.getAddress(); + } + + async fetchPendingTransactions() { + // nop + } + + async decodeInvoiceRemote(invoice: string) { + throw new Error('decodeInvoiceRemote not implemented'); + } + + async allowOnchainAddress() { + return true; + } + + async fetchBtcAddress() { + if (!this._wallet) await this.init(); + assert(this._wallet, 'Arkade wallet not initialized'); + + this.refill_addressess = this.refill_addressess || []; + const address = await this._wallet.getBoardingAddress(); + if (!this.refill_addressess.includes(address)) { + this.refill_addressess.push(address); + } + } + + async refreshAcessToken() { + // nop + } + + async checkLogin() { + // nop + } + + async authorize() { + // nop + } + + isInvoiceGeneratedByWallet(paymentRequest: string) { + // "Did we generate this invoice?" is a swap-history question: reverse swaps + // are the invoices we create to receive. Check _swapHistory directly so the + // answer is independent of how the display list coalesces a settled swap + // (whose row is enriched onto its native Ark leg rather than kept as a + // `swap-` row, and so would otherwise drop out of getTransactions()). + return this._swapHistory.some( + swap => + swap.type === 'reverse' && + ((swap.request as any)?.invoice === paymentRequest || (swap.response as any)?.invoice === paymentRequest), + ); + } + + async createAccount() { + // nop + } + + accessTokenExpired() { + return false; + } + + refreshTokenExpired() { + return false; + } + + private async _attemptBoardUtxos() { + // Refresh the boarding UTXO list so getTransactions() can render "Pending + // refill" rows. The actual onboard intent is now driven by the SDK's + // VtxoManager polling loop (enabled via settlementConfig on Wallet.create); + // running Ramps.onboard here in parallel would double-submit the same + // inputs and race the SDK's per-input cooldown bookkeeping. + const namespace = this.getNamespace(); + if (boardingLock[namespace]) return; + if (!this._wallet) return; + + boardingLock[namespace] = true; + try { + this._boardingUtxos = await this._wallet.getBoardingUtxos(); + } finally { + boardingLock[namespace] = false; + } + } + + isAddressValid(address: string): boolean { + try { + const decoded = bech32m.decode(address, 1000); + if (decoded.prefix !== 'ark') return false; + if (decoded.words[0] !== 0) return false; + if (decoded.words.length !== 104) return false; + return true; + } catch (_) { + return false; + } + } + + // Per-swap refund + import-time restore + SDK event forwarding. + // These are thin wrappers over `ArkadeSwaps`. We do not add app-side polling + // or reliability layers — the SDK owns swap reliability internally + // (auto-claims reverse swaps via SwapManager; refundVHTLC reports + // swept/skipped). UI code calls refundSwap from the swap detail screen and + // subscribes to status updates via subscribeToSwapEvents. + + getSwapById(id: string): BoltzSwap | undefined { + return this._swapHistory.find(swap => swap.id === id); + } + + isSwapClaimable(swap: BoltzSwap): boolean { + return isReverseSwapClaimable(swap) || isChainSwapClaimable(swap); + } + + isSwapRefundable(swap: BoltzSwap): boolean { + return isSubmarineSwapRefundable(swap) || isChainSwapRefundable(swap); + } + + // Forward SwapManager status transitions to a single UI callback so screens + // can re-render the moment the SDK observes a new status (e.g. reverse + // `transaction.mempool` → `invoice.settled` after the SDK's auto-claim), + // instead of waiting for the 3s polling tick in the invoice viewer. No-op + // (returns an inert unsubscribe) if init hasn't populated `_arkadeSwaps` + // yet — callers re-subscribe whenever the wallet ref changes. + subscribeToSwapEvents(callback: (swap: BoltzSwap) => void): () => void { + const sm = this._arkadeSwaps?.getSwapManager(); + if (!sm) return () => {}; + sm.onSwapUpdate(callback).catch(() => {}); + return () => sm.offSwapUpdate(callback); + } + + async refundSwap(swap: BoltzSubmarineSwap): Promise { + if (!this._wallet) await this.init(); + if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized'); + const outcome = await this._arkadeSwaps.refundVHTLC(swap); + await this.fetchTransactions(); + await this.fetchBalance(); + return outcome; + } + + async restoreSwaps(): Promise { + const namespace = this.getNamespace(); + let inFlight = restoreInFlight.get(namespace); + if (!inFlight) { + inFlight = (async () => { + if (!this._wallet) await this.init(); + if (!this._arkadeSwaps) throw new Error('ArkadeSwaps not initialized'); + await this._arkadeSwaps.restoreSwaps(); + this._swapHistory = await this._arkadeSwaps.getSwapHistory(); + this._lastTxFetch = +new Date(); + })(); + restoreInFlight.set(namespace, inFlight); + inFlight + .finally(() => { + if (restoreInFlight.get(namespace) === inFlight) restoreInFlight.delete(namespace); + }) + .catch(() => { + // Same rejection is delivered to the awaiting caller below; silence + // the cleanup chain so it isn't an unhandled rejection. + }); + await inFlight; + } else { + // Join an in-flight restore. The IIFE only writes to the instance that + // created it, so pull results into this instance once the shared work + // completes. + await inFlight; + const cachedSwaps = staticSwapsCache[namespace]; + if (cachedSwaps) { + this._swapHistory = await cachedSwaps.getSwapHistory(); + this._lastTxFetch = +new Date(); + } + } + } + + /** + * Cleanup hook invoked when the wallet is removed from BlueWallet storage. + * Drains any in-flight init so its post-await tail can no longer repopulate + * staticWalletCache / staticSwapsCache / realmInstances after we've cleared + * them, then closes the per-wallet Realm, deletes the Realm files, and + * resets the Keychain entry. Errors are scoped here and never thrown to the + * deletion path. + */ + async onDelete(): Promise { + if (!this.secret) return; // nothing to clean + const namespace = this.getNamespace(); + + delete boardingLock[namespace]; + + // If init() is racing with us, await its settlement before clearing caches. + // Without this drain, the IIFE in init() would write to staticWalletCache / + // staticSwapsCache after our delete and the realm adapter would re-cache the + // open Realm, resurrecting state for an already-deleted wallet. Note that + // the racing init's `await inFlight` continuation runs *before* ours (it + // was registered earlier), so when we resume here, init has already + // re-assigned this._wallet / this._arkadeSwaps and populated the caches. + // We then clear everything in one pass. + const inFlightInit = initInFlight.get(namespace); + if (inFlightInit) { + try { + await inFlightInit; + } catch { + // init's caller already received the rejection; we just need it to settle. + } + } + + // Stop SwapManager + VtxoManager loops before tearing down storage so + // their background timers / WebSocket / settlement polls don't keep + // running against a wallet whose Realm we're about to delete. + const cachedSwaps = staticSwapsCache[namespace]; + const cachedWallet = staticWalletCache[namespace]; + + this._wallet = undefined; + this._arkadeSwaps = undefined; + delete staticWalletCache[namespace]; + delete staticSwapsCache[namespace]; + initInFlight.delete(namespace); + + // Type guards: real SDK objects always have dispose; unit-test stubs may not. + try { + if (typeof cachedSwaps?.dispose === 'function') await cachedSwaps.dispose(); + } catch (e: any) { + console.log(`[LightningArkWallet] arkadeSwaps.dispose failed for ${namespace}:`, e?.message ?? e); + } + try { + if (typeof cachedWallet?.dispose === 'function') await cachedWallet.dispose(); + } catch (e: any) { + console.log(`[LightningArkWallet] wallet.dispose failed for ${namespace}:`, e?.message ?? e); + } + + try { + await deleteArkadeRealm(namespace); + } catch (e: any) { + console.log(`[LightningArkWallet] onDelete cleanup failed for ${namespace}:`, e?.message ?? e); + } + } +} diff --git a/class/wallets/lightning-custodian-wallet.js b/class/wallets/lightning-custodian-wallet.js deleted file mode 100644 index 9def152da52..00000000000 --- a/class/wallets/lightning-custodian-wallet.js +++ /dev/null @@ -1,715 +0,0 @@ -import { LegacyWallet } from './legacy-wallet'; -import Frisbee from 'frisbee'; -import bolt11 from 'bolt11'; -import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; -import { isTorDaemonDisabled } from '../../blue_modules/environment'; -const torrific = require('../../blue_modules/torrific'); -export class LightningCustodianWallet extends LegacyWallet { - static type = 'lightningCustodianWallet'; - static typeReadable = 'Lightning'; - - constructor(props) { - super(props); - this.setBaseURI(); // no args to init with default value - this.init(); - this.refresh_token = ''; - this.access_token = ''; - this._refresh_token_created_ts = 0; - this._access_token_created_ts = 0; - this.refill_addressess = []; - this.pending_transactions_raw = []; - this.user_invoices_raw = []; - this.info_raw = false; - this.preferredBalanceUnit = BitcoinUnit.SATS; - this.chain = Chain.OFFCHAIN; - } - - /** - * requires calling init() after setting - * - * @param URI - */ - setBaseURI(URI) { - this.baseURI = URI; - } - - getBaseURI() { - return this.baseURI; - } - - allowSend() { - return true; - } - - getAddress() { - if (this.refill_addressess.length > 0) { - return this.refill_addressess[0]; - } else { - return undefined; - } - } - - getSecret() { - return this.secret + '@' + this.baseURI; - } - - timeToRefreshBalance() { - return (+new Date() - this._lastBalanceFetch) / 1000 > 300; // 5 min - } - - timeToRefreshTransaction() { - return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min - } - - static fromJson(param) { - const obj = super.fromJson(param); - obj.init(); - return obj; - } - - async init() { - // un-cache refill onchain addresses on cold start. should help for cases when certain lndhub - // is turned off permanently, so users cant pull refill address from cache and send money to a black hole - this.refill_addressess = []; - - this._api = new Frisbee({ - baseURI: this.baseURI, - }); - const isTorDisabled = await isTorDaemonDisabled(); - - if (!isTorDisabled && this.baseURI && this.baseURI?.indexOf('.onion') !== -1) { - this._api = new torrific.Torsbee({ - baseURI: this.baseURI, - }); - } - } - - accessTokenExpired() { - return (+new Date() - this._access_token_created_ts) / 1000 >= 3600 * 2; // 2h - } - - refreshTokenExpired() { - return (+new Date() - this._refresh_token_created_ts) / 1000 >= 3600 * 24 * 7; // 7d - } - - generate() { - // nop - } - - async createAccount(isTest) { - const response = await this._api.post('/create', { - body: { partnerid: 'bluewallet', accounttype: (isTest && 'test') || 'common' }, - headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, - }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + (json.message ? json.message : json.error) + ' (code ' + json.code + ')'); - } - - if (!json.login || !json.password) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - this.secret = 'lndhub://' + json.login + ':' + json.password; - } - - async payInvoice(invoice, freeAmount = 0) { - const response = await this._api.post('/payinvoice', { - body: { invoice, amount: freeAmount }, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - if (response.originalResponse && typeof response.originalResponse === 'string') { - try { - response.originalResponse = JSON.parse(response.originalResponse); - } catch (_) {} - } - - if (response.originalResponse && response.originalResponse.status && response.originalResponse.status === 503) { - throw new Error('Payment is in transit'); - } - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - this.last_paid_invoice_result = json; - } - - /** - * Returns list of LND invoices created by user - * - * @return {Promise.} - */ - async getUserInvoices(limit = false) { - let limitString = ''; - if (limit) limitString = '?limit=' + parseInt(limit, 10); - const response = await this._api.get('/getuserinvoices' + limitString, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (limit) { - // need to merge existing invoices with the ones that arrived - // but the ones received later should overwrite older ones - - for (const oldInvoice of this.user_invoices_raw) { - // iterate all OLD invoices - let found = false; - for (const newInvoice of json) { - // iterate all NEW invoices - if (newInvoice.payment_request === oldInvoice.payment_request) found = true; - } - - if (!found) { - // if old invoice is not found in NEW array, we simply add it: - json.push(oldInvoice); - } - } - } - - this.user_invoices_raw = json.sort(function (a, b) { - return a.timestamp - b.timestamp; - }); - - return this.user_invoices_raw; - } - - /** - * Basically the same as this.getUserInvoices() but saves invoices list - * to internal variable - * - * @returns {Promise} - */ - async fetchUserInvoices() { - await this.getUserInvoices(); - } - - isInvoiceGeneratedByWallet(paymentRequest) { - return this.user_invoices_raw.some(invoice => invoice.payment_request === paymentRequest); - } - - weOwnAddress(address) { - return this.refill_addressess.some(refillAddress => address === refillAddress); - } - - async addInvoice(amt, memo) { - const response = await this._api.post('/addinvoice', { - body: { amt: amt + '', memo }, - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.originalResponse)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.r_hash || !json.pay_req) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - return json.pay_req; - } - - /** - * Uses login & pass stored in `this.secret` to authorize - * and set internal `access_token` & `refresh_token` - * - * @return {Promise.} - */ - async authorize() { - let login, password; - if (this.secret.indexOf('blitzhub://') !== -1) { - login = this.secret.replace('blitzhub://', '').split(':')[0]; - password = this.secret.replace('blitzhub://', '').split(':')[1]; - } else { - login = this.secret.replace('lndhub://', '').split(':')[0]; - password = this.secret.replace('lndhub://', '').split(':')[1]; - } - const response = await this._api.post('/auth?type=auth', { - body: { login, password }, - headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.access_token || !json.refresh_token) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - this.refresh_token = json.refresh_token; - this.access_token = json.access_token; - this._refresh_token_created_ts = +new Date(); - this._access_token_created_ts = +new Date(); - } - - async checkLogin() { - if (this.accessTokenExpired() && this.refreshTokenExpired()) { - // all tokens expired, only option is to login with login and password - return this.authorize(); - } - - if (this.accessTokenExpired()) { - // only access token expired, so only refreshing it - let refreshedOk = true; - try { - await this.refreshAcessToken(); - } catch (Err) { - refreshedOk = false; - } - - if (!refreshedOk) { - // something went wrong, lets try to login regularly - return this.authorize(); - } - } - } - - async refreshAcessToken() { - const response = await this._api.post('/auth?type=refresh_token', { - body: { refresh_token: this.refresh_token }, - headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.access_token || !json.refresh_token) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - this.refresh_token = json.refresh_token; - this.access_token = json.access_token; - this._refresh_token_created_ts = +new Date(); - this._access_token_created_ts = +new Date(); - } - - async fetchBtcAddress() { - const response = await this._api.get('/getbtc', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - this.refill_addressess = []; - - for (const arr of json) { - this.refill_addressess.push(arr.address); - } - } - - async getAddressAsync() { - await this.fetchBtcAddress(); - return this.getAddress(); - } - - async allowOnchainAddress() { - if (this.getAddress() !== undefined && this.getAddress() !== null) { - return true; - } else { - await this.fetchBtcAddress(); - return this.getAddress() !== undefined && this.getAddress() !== null; - } - } - - getTransactions() { - let txs = []; - this.pending_transactions_raw = this.pending_transactions_raw || []; - this.user_invoices_raw = this.user_invoices_raw || []; - this.transactions_raw = this.transactions_raw || []; - txs = txs.concat(this.pending_transactions_raw.slice(), this.transactions_raw.slice().reverse(), this.user_invoices_raw.slice()); // slice so array is cloned - // transforming to how wallets/list screen expects it - for (const tx of txs) { - tx.walletID = this.getID(); - if (tx.amount) { - // pending tx - tx.amt = tx.amount * -100000000; - tx.fee = 0; - tx.timestamp = tx.time; - tx.memo = 'On-chain transaction'; - } - - if (typeof tx.amt !== 'undefined' && typeof tx.fee !== 'undefined') { - // lnd tx outgoing - tx.value = parseInt((tx.amt * 1 + tx.fee * 1) * -1, 10); - } - - if (tx.type === 'paid_invoice') { - tx.memo = tx.memo || 'Lightning payment'; - if (tx.value > 0) tx.value = tx.value * -1; // value already includes fee in it (see lndhub) - // outer code expects spending transactions to of negative value - } - - if (tx.type === 'bitcoind_tx') { - tx.memo = 'On-chain transaction'; - } - - if (tx.type === 'user_invoice') { - // incoming ln tx - tx.value = parseInt(tx.amt, 10); - tx.memo = tx.description || 'Lightning invoice'; - } - - tx.received = new Date(tx.timestamp * 1000).toString(); - } - return txs.sort(function (a, b) { - return b.timestamp - a.timestamp; - }); - } - - async fetchPendingTransactions() { - const response = await this._api.get('/getpending', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - this.pending_transactions_raw = json; - } - - async fetchTransactions() { - // TODO: iterate over all available pages - const limit = 10; - let queryRes = ''; - const offset = 0; - queryRes += '?limit=' + limit; - queryRes += '&offset=' + offset; - - const response = await this._api.get('/gettxs' + queryRes, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!Array.isArray(json)) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - this._lastTxFetch = +new Date(); - this.transactions_raw = json; - } - - getBalance() { - return this.balance; - } - - async fetchBalance(noRetry) { - await this.checkLogin(); - - const response = await this._api.get('/balance', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - if (json.code * 1 === 1 && !noRetry) { - await this.authorize(); - return this.fetchBalance(true); - } - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.BTC || typeof json.BTC.AvailableBalance === 'undefined') { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - this.balance_raw = json; - this.balance = json.BTC.AvailableBalance; - this._lastBalanceFetch = +new Date(); - } - - /** - * Example return: - * { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', - * payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4', - * num_satoshis: '100', - * timestamp: '1535116657', - * expiry: '3600', - * description: 'hundredSatoshis blitzhub', - * description_hash: '', - * fallback_addr: '', - * cltv_expiry: '10', - * route_hints: [] } - * - * @param invoice BOLT invoice string - * @return {payment_hash: string} - */ - decodeInvoice(invoice) { - const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); - - const decoded = { - destination: payeeNodeKey, - num_satoshis: satoshis ? satoshis.toString() : '0', - num_millisatoshis: millisatoshis ? millisatoshis.toString() : '0', - timestamp: timestamp.toString(), - fallback_addr: '', - route_hints: [], - }; - - for (let i = 0; i < tags.length; i++) { - const { tagName, data } = tags[i]; - switch (tagName) { - case 'payment_hash': - decoded.payment_hash = data; - break; - case 'purpose_commit_hash': - decoded.description_hash = data; - break; - case 'min_final_cltv_expiry': - decoded.cltv_expiry = data.toString(); - break; - case 'expire_time': - decoded.expiry = data.toString(); - break; - case 'description': - decoded.description = data; - break; - } - } - - if (!decoded.expiry) decoded.expiry = '3600'; // default - - if (parseInt(decoded.num_satoshis, 10) === 0 && decoded.num_millisatoshis > 0) { - decoded.num_satoshis = (decoded.num_millisatoshis / 1000).toString(); - } - - return (this.decoded_invoice_raw = decoded); - } - - async fetchInfo() { - const response = await this._api.get('/getinfo', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.identity_pubkey) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - this.info_raw = json; - } - - static async isValidNodeAddress(address) { - const isTorDisabled = await isTorDaemonDisabled(); - const isTor = address.indexOf('.onion') !== -1; - const apiCall = - isTor && !isTorDisabled - ? new torrific.Torsbee({ - baseURI: address, - }) - : new Frisbee({ - baseURI: address, - }); - const response = await apiCall.get('/getinfo', { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - }, - }); - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.code && json.code !== 1) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - return true; - } - - allowReceive() { - return true; - } - - allowSignVerifyMessage() { - return false; - } - - /** - * Example return: - * { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', - * payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4', - * num_satoshis: '100', - * timestamp: '1535116657', - * expiry: '3600', - * description: 'hundredSatoshis blitzhub', - * description_hash: '', - * fallback_addr: '', - * cltv_expiry: '10', - * route_hints: [] } - * - * @param invoice BOLT invoice string - * @return {Promise.} - */ - async decodeInvoiceRemote(invoice) { - await this.checkLogin(); - - const response = await this._api.get('/decodeinvoice?invoice=' + invoice, { - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - Authorization: 'Bearer' + ' ' + this.access_token, - }, - }); - - const json = response.body; - if (typeof json === 'undefined') { - throw new Error('API failure: ' + response.err + ' ' + JSON.stringify(response.body)); - } - - if (json && json.error) { - throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); - } - - if (!json.payment_hash) { - throw new Error('API unexpected response: ' + JSON.stringify(response.body)); - } - - return (this.decoded_invoice_raw = json); - } - - weOwnTransaction(txid) { - for (const tx of this.getTransactions()) { - if (tx && tx.payment_hash && tx.payment_hash === txid) return true; - } - - return false; - } - - authenticate(lnurl) { - return lnurl.authenticate(this.secret); - } -} - -/* - - - -pending tx: - - [ { amount: 0.00078061, - account: '521172', - address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF', - category: 'receive', - confirmations: 0, - blockhash: '', - blockindex: 0, - blocktime: 0, - txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62', - walletconflicts: [], - time: 1535024434, - timereceived: 1535024434 } ] - - -tx: - - [ { amount: 0.00078061, - account: '521172', - address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF', - category: 'receive', - confirmations: 5, - blockhash: '0000000000000000000edf18e9ece18e449c6d8eed1f729946b3531c32ee9f57', - blockindex: 693, - blocktime: 1535024914, - txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62', - walletconflicts: [], - time: 1535024434, - timereceived: 1535024434 } ] - - */ diff --git a/class/wallets/lightning-custodian-wallet.ts b/class/wallets/lightning-custodian-wallet.ts new file mode 100644 index 00000000000..ffbb5b1d105 --- /dev/null +++ b/class/wallets/lightning-custodian-wallet.ts @@ -0,0 +1,668 @@ +import bolt11 from 'bolt11'; +import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; +import { fetch } from '../../util/fetch'; +import { LegacyWallet } from './legacy-wallet'; +import { DecodedInvoice, LightningTransaction, Transaction } from './types'; + +const _staticDecodedInvoiceCache: Record = {}; + +export class LightningCustodianWallet extends LegacyWallet { + static readonly type = 'lightningCustodianWallet'; + static readonly typeReadable = 'Lightning'; + static readonly subtitleReadable = 'LNDhub'; + // @ts-ignore: override + public readonly type = LightningCustodianWallet.type; + // @ts-ignore: override + public readonly typeReadable = LightningCustodianWallet.typeReadable; + + baseURI?: string; + refresh_token: string = ''; + access_token: string = ''; + _refresh_token_created_ts: number = 0; + _access_token_created_ts: number = 0; + refill_addressess: string[] = []; + pending_transactions_raw: any[] = []; + transactions_raw: any[] = []; + user_invoices_raw: any[] = []; + preferredBalanceUnit = BitcoinUnit.SATS; + chain = Chain.OFFCHAIN; + last_paid_invoice_result?: any; + + /** + * requires calling init() after setting + * + * @param URI + */ + setBaseURI(URI: string | undefined) { + this.baseURI = URI?.endsWith('/') ? URI.slice(0, -1) : URI; + } + + getBaseURI() { + return this.baseURI; + } + + getAddress(): string | false { + if (this.refill_addressess.length > 0) { + return this.refill_addressess[0]; + } else { + return false; + } + } + + getSecret() { + return this.secret + '@' + this.baseURI; + } + + timeToRefreshTransaction() { + return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min + } + + async init() { + // un-cache refill onchain addresses on cold start. should help for cases when certain lndhub + // is turned off permanently, so users cant pull refill address from cache and send money to a black hole + this.refill_addressess = []; + } + + accessTokenExpired() { + return (+new Date() - this._access_token_created_ts) / 1000 >= 3600 * 2; // 2h + } + + refreshTokenExpired() { + return (+new Date() - this._refresh_token_created_ts) / 1000 >= 3600 * 24 * 7; // 7d + } + + generate(): Promise { + // nop + return Promise.resolve(); + } + + async createAccount(isTest: boolean = false) { + const response = await fetch(this.baseURI + '/create', { + method: 'POST', + body: JSON.stringify({ partnerid: 'bluewallet', accounttype: (isTest && 'test') || 'common' }), + headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, + }); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + (json.message ? json.message : json.error) + ' (code ' + json.code + ')'); + } + + if (!json.login || !json.password) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + this.secret = 'lndhub://' + json.login + ':' + json.password; + } + + async payInvoice(invoice: string, freeAmount: number = 0) { + const response = await fetch(this.baseURI + '/payinvoice', { + method: 'POST', + body: JSON.stringify({ invoice, amount: freeAmount }), + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + this.last_paid_invoice_result = json; + } + + /** + * Returns list of LND invoices created by user + * + * @return {Promise.} + */ + async getUserInvoices(limit: number | false = false) { + let limitString = ''; + if (limit) limitString = '?limit=' + parseInt(limit as unknown as string, 10); + const response = await fetch(this.baseURI + '/getuserinvoices' + limitString, { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (limit) { + // need to merge existing invoices with the ones that arrived + // but the ones received later should overwrite older ones + + for (const oldInvoice of this.user_invoices_raw) { + // if old invoice is not found in NEW array, we simply add it: + if (!json.some((newInvoice: { payment_request: string }) => newInvoice.payment_request === oldInvoice.payment_request)) { + json.push(oldInvoice); + } + } + } + + this.user_invoices_raw = json.sort(function (a: { timestamp: number }, b: { timestamp: number }) { + return a.timestamp - b.timestamp; + }); + + return this.user_invoices_raw; + } + + /** + * Basically the same as this.getUserInvoices() but saves invoices list + * to internal variable + * + * @returns {Promise} + */ + async fetchUserInvoices() { + await this.getUserInvoices(); + } + + isInvoiceGeneratedByWallet(paymentRequest: string) { + return this.user_invoices_raw.some(invoice => invoice.payment_request === paymentRequest); + } + + weOwnAddress(address: string) { + return this.refill_addressess.some(refillAddress => address === refillAddress); + } + + async addInvoice(amt: number, memo: string) { + const response = await fetch(this.baseURI + '/addinvoice', { + method: 'POST', + body: JSON.stringify({ amt: amt + '', memo }), + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!json.r_hash || !json.pay_req) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + return json.pay_req; + } + + /** + * Uses login & pass stored in `this.secret` to authorize + * and set internal `access_token` & `refresh_token` + * + * @return {Promise.} + */ + async authorize() { + const [login, password] = this.secret.replace(/^(blitzhub|lndhub):\/\//, '').split(':'); + const response = await fetch(this.baseURI + '/auth?type=auth', { + method: 'POST', + body: JSON.stringify({ login, password }), + headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!json.access_token || !json.refresh_token) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + this.refresh_token = json.refresh_token; + this.access_token = json.access_token; + this._refresh_token_created_ts = +new Date(); + this._access_token_created_ts = +new Date(); + } + + async checkLogin() { + if (this.accessTokenExpired() && this.refreshTokenExpired()) { + // all tokens expired, only option is to login with login and password + return this.authorize(); + } + + if (this.accessTokenExpired()) { + // only access token expired, so only refreshing it + let refreshedOk = true; + try { + await this.refreshAcessToken(); + } catch (Err) { + refreshedOk = false; + } + + if (!refreshedOk) { + // something went wrong, lets try to login regularly + return this.authorize(); + } + } + } + + async refreshAcessToken() { + const response = await fetch(this.baseURI + '/auth?type=refresh_token', { + method: 'POST', + body: JSON.stringify({ refresh_token: this.refresh_token }), + headers: { 'Access-Control-Allow-Origin': '*', 'Content-Type': 'application/json' }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!json.access_token || !json.refresh_token) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + this.refresh_token = json.refresh_token; + this.access_token = json.access_token; + this._refresh_token_created_ts = +new Date(); + this._access_token_created_ts = +new Date(); + } + + async fetchBtcAddress() { + const response = await fetch(this.baseURI + '/getbtc', { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + this.refill_addressess = []; + + for (const arr of json) { + this.refill_addressess.push(arr.address); + } + } + + async getAddressAsync() { + await this.fetchBtcAddress(); + return this.getAddress(); + } + + async allowOnchainAddress() { + if (this.getAddress() !== undefined && this.getAddress() !== null) { + return true; + } else { + await this.fetchBtcAddress(); + return this.getAddress() !== undefined && this.getAddress() !== null; + } + } + + getTransactions(): (Transaction & LightningTransaction)[] { + let txs: any = []; + txs = txs.concat(this.pending_transactions_raw.slice(), this.transactions_raw.slice().reverse(), this.user_invoices_raw.slice()); // slice so array is cloned + + for (const tx of txs) { + tx.walletID = this.getID(); + if (tx.amount) { + // pending tx + tx.amt = tx.amount * -100000000; + tx.fee = 0; + tx.memo = 'On-chain transaction'; + } + + if (typeof tx.amt !== 'undefined' && typeof tx.fee !== 'undefined') { + // lnd tx outgoing + tx.value = (tx.amt * 1 + tx.fee * 1) * -1; + } + + if (tx.type === 'paid_invoice') { + tx.memo = tx.memo || 'Lightning payment'; + if (tx.value > 0) tx.value = tx.value * -1; // value already includes fee in it (see lndhub) + // outer code expects spending transactions to of negative value + } + + if (tx.type === 'bitcoind_tx') { + tx.memo = 'On-chain transaction'; + } + + if (tx.type === 'user_invoice') { + // incoming ln tx + tx.value = parseInt(tx.amt, 10); + tx.fee = 0; + tx.memo = tx.description || 'Lightning invoice'; + } + + tx.timestamp = tx.timestamp || tx.time; + } + return txs.sort(function (a: { timestamp: number }, b: { timestamp: number }) { + return b.timestamp - a.timestamp; + }); + } + + async fetchPendingTransactions() { + const response = await fetch(this.baseURI + '/getpending', { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + this.pending_transactions_raw = json; + } + + async fetchTransactions() { + // TODO: iterate over all available pages + const limit = 10; + let queryRes = ''; + const offset = 0; + queryRes += '?limit=' + limit; + queryRes += '&offset=' + offset; + + const response = await fetch(this.baseURI + '/gettxs' + queryRes, { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!Array.isArray(json)) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + this._lastTxFetch = +new Date(); + this.transactions_raw = json; + } + + getBalance() { + return this.balance; + } + + async fetchBalance(noRetry?: boolean): Promise { + await this.checkLogin(); + + const response = await fetch(this.baseURI + '/balance', { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + if (json.code * 1 === 1 && !noRetry) { + await this.authorize(); + return this.fetchBalance(true); + } + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!json.BTC || typeof json.BTC.AvailableBalance === 'undefined') { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + this.balance = json.BTC.AvailableBalance; + this._lastBalanceFetch = +new Date(); + } + + /** + * Example return: + * { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', + * payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4', + * num_satoshis: '100', + * timestamp: '1535116657', + * expiry: '3600', + * description: 'hundredSatoshis blitzhub', + * description_hash: '', + * fallback_addr: '', + * cltv_expiry: '10', + * route_hints: [] } + * + * @param invoice BOLT invoice string + * @return {DecodedInvoice} + */ + decodeInvoice(invoice: string): DecodedInvoice { + if (_staticDecodedInvoiceCache[invoice]) return _staticDecodedInvoiceCache[invoice]; // cache hit + + const { payeeNodeKey, tags, satoshis, millisatoshis, timestamp } = bolt11.decode(invoice); + + const decoded: DecodedInvoice = { + destination: payeeNodeKey ?? '', + num_satoshis: satoshis ? +satoshis : 0, + num_millisatoshis: millisatoshis ? +millisatoshis : 0, + timestamp: timestamp ?? 0, + fallback_addr: '', + route_hints: [], + payment_hash: '', + expiry: 3600, // default + description: '', + description_hash: '', + cltv_expiry: '', + }; + + for (let i = 0; i < tags.length; i++) { + const { tagName, data } = tags[i]; + switch (tagName) { + case 'payment_hash': + decoded.payment_hash = String(data); + break; + case 'purpose_commit_hash': + decoded.description_hash = String(data); + break; + case 'min_final_cltv_expiry': + decoded.cltv_expiry = data.toString(); + break; + case 'expire_time': + decoded.expiry = +data; + break; + case 'description': + decoded.description = String(data); + break; + } + } + + if (!decoded.expiry) decoded.expiry = 3600; // default + + if (decoded.num_satoshis === 0 && decoded.num_millisatoshis > 0) { + decoded.num_satoshis = Math.floor(decoded.num_millisatoshis / 1000); + } + + _staticDecodedInvoiceCache[invoice] = decoded; + + return decoded; + } + + static async isValidNodeAddress(address: string): Promise { + const normalizedAddress = new URL('/getinfo', address.replace(/([^:]\/)\/+/g, '$1')); + + const response = await fetch(normalizedAddress.toString(), { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.code && json.code !== 1) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + return true; + } + + allowSignVerifyMessage() { + return false; + } + + /** + * Example return: + * { destination: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f', + * payment_hash: 'faf996300a468b668c58ca0702a12096475a0dd2c3dde8e812f954463966bcf4', + * num_satoshis: '100', + * timestamp: '1535116657', + * expiry: '3600', + * description: 'hundredSatoshis blitzhub', + * description_hash: '', + * fallback_addr: '', + * cltv_expiry: '10', + * route_hints: [] } + * + * @param invoice BOLT invoice string + * @return {Promise.} + */ + async decodeInvoiceRemote(invoice: string) { + await this.checkLogin(); + + const response = await fetch(this.baseURI + '/decodeinvoice?invoice=' + invoice, { + method: 'GET', + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + Authorization: 'Bearer' + ' ' + this.access_token, + }, + }); + + const json = await response.json(); + if (!json) { + throw new Error('API failure: ' + response.statusText); + } + + if (json.error) { + throw new Error('API error: ' + json.message + ' (code ' + json.code + ')'); + } + + if (!json.payment_hash) { + throw new Error('API unexpected response: ' + JSON.stringify(json)); + } + + return json; + } + + weOwnTransaction(txid: string) { + for (const tx of this.getTransactions()) { + if (tx && tx.payment_hash && tx.payment_hash === txid) return true; + } + + return false; + } + + authenticate(lnurl: any) { + return lnurl.authenticate(this.secret); + } + + getLatestTransactionTime(): string | 0 { + const transactions = this.getTransactions(); + if (transactions.length === 0) { + return 0; + } + return new Date(transactions.reduce((max: number, tx: any) => Math.max(max, tx.timestamp), 0) * 1000).toString(); + } + + isInvoiceExpired(invoice: string, currentTimestamp?: number): boolean { + currentTimestamp = currentTimestamp || Date.now() / 1000; // current ts in seconds + const decoded = this.decodeInvoice(invoice); + return decoded.timestamp + decoded.expiry < currentTimestamp; + } +} + +/* + + + +pending tx: + + [ { amount: 0.00078061, + account: '521172', + address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF', + category: 'receive', + confirmations: 0, + blockhash: '', + blockindex: 0, + blocktime: 0, + txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62', + walletconflicts: [], + time: 1535024434, + timereceived: 1535024434 } ] + + +tx: + + [ { amount: 0.00078061, + account: '521172', + address: '3F9seBGCJZQ4WJJHwGhrxeGXCGbrm5SNpF', + category: 'receive', + confirmations: 5, + blockhash: '0000000000000000000edf18e9ece18e449c6d8eed1f729946b3531c32ee9f57', + blockindex: 693, + blocktime: 1535024914, + txid: '28a74277e47c2d772ee8a40464209c90dce084f3b5de38a2f41b14c79e3bfc62', + walletconflicts: [], + time: 1535024434, + timereceived: 1535024434 } ] + + */ diff --git a/class/wallets/lightning-ldk-wallet.ts b/class/wallets/lightning-ldk-wallet.ts deleted file mode 100644 index 8a84ab68f23..00000000000 --- a/class/wallets/lightning-ldk-wallet.ts +++ /dev/null @@ -1,691 +0,0 @@ -import RNFS from 'react-native-fs'; -import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; -import RnLdk from 'rn-ldk/src/index'; -import { LightningCustodianWallet } from './lightning-custodian-wallet'; -import SyncedAsyncStorage from '../synced-async-storage'; -import { randomBytes } from '../rng'; -import * as bip39 from 'bip39'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; -import bolt11 from 'bolt11'; -import { SegwitBech32Wallet } from './segwit-bech32-wallet'; -import alert from '../../components/Alert'; -const bitcoin = require('bitcoinjs-lib'); - -export class LightningLdkWallet extends LightningCustodianWallet { - static type = 'lightningLdk'; - static typeReadable = 'Lightning LDK'; - private _listChannels: any[] = []; - private _listPayments: any[] = []; - private _listInvoices: any[] = []; - private _nodeConnectionDetailsCache: any = {}; // pubkey -> {pubkey, host, port, ts} - private _refundAddressScriptHex: string = ''; - private _lastTimeBlockchainCheckedTs: number = 0; - private _unwrapFirstExternalAddressFromMnemonicsCache: string = ''; - private static _predefinedNodes: Record = { - Bitrefill: '03d607f3e69fd032524a867b288216bfab263b6eaee4e07783799a6fe69bb84fac@3.237.23.179:9735', - 'OpenNode.com': '03abf6f44c355dec0d5aa155bdbdd6e0c8fefe318eff402de65c6eb2e1be55dc3e@3.132.230.42:9735', - Fold: '02816caed43171d3c9854e3b0ab2cf0c42be086ff1bd4005acc2a5f7db70d83774@35.238.153.25:9735', - 'Moon (paywithmoon.com)': '025f1456582e70c4c06b61d5c8ed3ce229e6d0db538be337a2dc6d163b0ebc05a5@52.86.210.65:9735', - 'coingate.com': '0242a4ae0c5bef18048fbecf995094b74bfb0f7391418d71ed394784373f41e4f3@3.124.63.44:9735', - 'Blockstream Store': '02df5ffe895c778e10f7742a6c5b8a0cefbe9465df58b92fadeb883752c8107c8f@35.232.170.67:9735', - ACINQ: '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f@3.33.236.230:9735', - }; - - static getPredefinedNodes() { - return LightningLdkWallet._predefinedNodes; - } - - static pubkeyToAlias(pubkeyHex: string) { - for (const key of Object.keys(LightningLdkWallet._predefinedNodes)) { - const val = LightningLdkWallet._predefinedNodes[key]; - if (val.startsWith(pubkeyHex)) return key; - } - - return pubkeyHex; - } - - constructor(props: any) { - super(props); - this.preferredBalanceUnit = BitcoinUnit.SATS; - this.chain = Chain.OFFCHAIN; - this.user_invoices_raw = []; // compatibility with other lightning wallet class - } - - valid() { - try { - const entropy = bip39.mnemonicToEntropy(this.secret.replace('ldk://', '')); - return entropy.length === 64 || entropy.length === 32; - } catch (_) {} - - return false; - } - - async stop() { - return RnLdk.stop(); - } - - async wipeLndDir() {} - - async listPeers() { - return RnLdk.listPeers(); - } - - async listChannels() { - try { - // exception might be in case of incompletely-started LDK. then just ignore and return cached version - this._listChannels = await RnLdk.listChannels(); - } catch (_) {} - - return this._listChannels; - } - - async getLndTransactions() { - return []; - } - - async getInfo() { - const identityPubkey = await RnLdk.getNodeId(); - return { - identityPubkey, - }; - } - - allowSend() { - return true; - } - - timeToCheckBlockchain() { - return +new Date() - this._lastTimeBlockchainCheckedTs > 5 * 60 * 1000; // 5 min, half of block time - } - - async fundingStateStepFinalize(txhex: string) { - return RnLdk.openChannelStep2(txhex); - } - - async getMaturingBalance(): Promise { - return RnLdk.getMaturingBalance(); - } - - async getMaturingHeight(): Promise { - return RnLdk.getMaturingHeight(); - } - - /** - * Probes getNodeId() call. if its available - LDK has started - * - * @return {Promise} - */ - async isStarted() { - let rez; - try { - rez = await Promise.race([new Promise(resolve => setTimeout(() => resolve('timeout'), 1000)), RnLdk.getNodeId()]); - } catch (_) {} - - if (rez === 'timeout' || !rez) { - return false; - } - - return true; - } - - /** - * Waiter till getNodeId() starts to respond. Returns true if it eventually does, - * false in case of timeout. - * - * @return {Promise} - */ - async waitTillStarted() { - for (let c = 0; c < 30; c++) { - if (await this.isStarted()) return true; - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - } - - return false; - } - - async openChannel(pubkeyHex: string, host: string, amountSats: number, privateChannel: boolean) { - let triedToConnect = false; - let port = 9735; - - if (host.includes(':')) { - const splitted = host.split(':'); - host = splitted[0]; - port = +splitted[1]; - } - - for (let c = 0; c < 20; c++) { - const peers = await this.listPeers(); - if (peers.includes(pubkeyHex)) { - // all good, connected, lets open channel - return await RnLdk.openChannelStep1(pubkeyHex, +amountSats); - } - - if (!triedToConnect) { - triedToConnect = true; - await RnLdk.connectPeer(pubkeyHex, host, +port); - } - - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - } - - throw new Error('timeout waiting for peer connection'); - } - - async connectPeer(pubkeyHex: string, host: string, port: number) { - return RnLdk.connectPeer(pubkeyHex, host, +port); - } - - async lookupNodeConnectionDetailsByPubkey(pubkey: string) { - // first, trying cache: - if (this._nodeConnectionDetailsCache[pubkey] && +new Date() - this._nodeConnectionDetailsCache[pubkey].ts < 4 * 7 * 24 * 3600 * 1000) { - // cache hit - return this._nodeConnectionDetailsCache[pubkey]; - } - - // doing actual fetch and filling cache: - const response = await fetch(`https://1ml.com/node/${pubkey}/json`); - const json = await response.json(); - if (json && json.addresses && Array.isArray(json.addresses)) { - for (const address of json.addresses) { - if (address.network === 'tcp') { - const ret = { - pubkey, - host: address.addr.split(':')[0], - port: parseInt(address.addr.split(':')[1], 10), - }; - - this._nodeConnectionDetailsCache[pubkey] = Object.assign({}, ret, { ts: +new Date() }); - - return ret; - } - } - } - } - - getAddress() { - return undefined; - } - - getSecret() { - return this.secret; - } - - timeToRefreshBalance() { - return (+new Date() - this._lastBalanceFetch) / 1000 > 300; // 5 min - } - - timeToRefreshTransaction() { - return (+new Date() - this._lastTxFetch) / 1000 > 300; // 5 min - } - - async generate() { - const buf = await randomBytes(16); - this.secret = 'ldk://' + bip39.entropyToMnemonic(buf.toString('hex')); - } - - getEntropyHex() { - let ret = bip39.mnemonicToEntropy(this.secret.replace('ldk://', '')); - while (ret.length < 64) ret = '0' + ret; - return ret; - } - - getStorageNamespace() { - return RnLdk.getStorage().namespace; - } - - static async _decodeInvoice(invoice: string) { - return bolt11.decode(invoice); - } - - static async _script2address(scriptHex: string) { - return bitcoin.address.fromOutputScript(Buffer.from(scriptHex, 'hex')); - } - - async selftest() { - await RnLdk.getStorage().selftest(); - await RnLdk.selftest(); - } - - async init() { - if (!this.getSecret()) return; - console.warn('starting ldk'); - - try { - // providing simple functions that RnLdk would otherwise rely on 3rd party APIs - RnLdk.provideDecodeInvoiceFunc(LightningLdkWallet._decodeInvoice); - RnLdk.provideScript2addressFunc(LightningLdkWallet._script2address); - const syncedStorage = new SyncedAsyncStorage(this.getEntropyHex()); - // await syncedStorage.selftest(); - // await RnLdk.selftest(); - // console.warn('selftest passed'); - await syncedStorage.synchronize(); - - RnLdk.setStorage(syncedStorage); - if (this._refundAddressScriptHex) { - await RnLdk.setRefundAddressScript(this._refundAddressScriptHex); - } else { - // fallback, unwrapping address from bip39 mnemonic we have - const address = this.unwrapFirstExternalAddressFromMnemonics(); - await this.setRefundAddress(address); - } - await RnLdk.start(this.getEntropyHex(), RNFS.DocumentDirectoryPath); - - this._execInBackground(this.reestablishChannels); - if (this.timeToCheckBlockchain()) this._execInBackground(this.checkBlockchain); - } catch (error: any) { - alert('LDK init error: ' + error.message); - } - } - - unwrapFirstExternalAddressFromMnemonics() { - if (this._unwrapFirstExternalAddressFromMnemonicsCache) return this._unwrapFirstExternalAddressFromMnemonicsCache; // cache hit - const hd = new HDSegwitBech32Wallet(); - hd.setSecret(this.getSecret().replace('ldk://', '')); - const address = hd._getExternalAddressByIndex(0); - this._unwrapFirstExternalAddressFromMnemonicsCache = address; - return address; - } - - unwrapFirstExternalWIFFromMnemonics() { - const hd = new HDSegwitBech32Wallet(); - hd.setSecret(this.getSecret().replace('ldk://', '')); - return hd._getExternalWIFByIndex(0); - } - - async checkBlockchain() { - this._lastTimeBlockchainCheckedTs = +new Date(); - return RnLdk.checkBlockchain(); - } - - async payInvoice(invoice: string, freeAmount = 0) { - const decoded = this.decodeInvoice(invoice); - - // if its NOT zero amount invoice, we forcefully reset passed amount argument so underlying LDK code - // would extract amount from bolt11 - if (decoded.num_satoshis && parseInt(decoded.num_satoshis, 10) > 0) freeAmount = 0; - - if (await this.channelsNeedReestablish()) { - await this.reestablishChannels(); - await this.waitForAtLeastOneChannelBecomeActive(); - } - - const result = await RnLdk.payInvoice(invoice, freeAmount); - if (!result) throw new Error('Failed'); - - // ok, it was sent. now, waiting for an event that it was _actually_ paid: - for (let c = 0; c < 60; c++) { - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - - for (const sentPayment of RnLdk.sentPayments || []) { - const paidHash = LightningLdkWallet.preimage2hash(sentPayment.payment_preimage); - if (paidHash === decoded.payment_hash) { - this._listPayments = this._listPayments || []; - this._listPayments.push( - Object.assign({}, sentPayment, { - memo: decoded.description || 'Lightning payment', - value: (freeAmount || decoded.num_satoshis) * -1, - received: +new Date(), - payment_preimage: sentPayment.payment_preimage, - payment_hash: decoded.payment_hash, - }), - ); - return; - } - } - - for (const failedPayment of RnLdk.failedPayments || []) { - if (failedPayment.payment_hash === decoded.payment_hash) throw new Error(JSON.stringify(failedPayment)); - } - } - - // no? lets just throw timeout error - throw new Error('Payment timeout'); - } - - /** - * In case user initiated channel opening, and then lost peer connection (i.e. app went in background for an - * extended period of time), when user gets back to the app the channel might already have enough confirmations, - * but will never be acknowledged as 'established' by LDK until peer reconnects so that ldk & peer can negotiate and - * agree that channel is now established - */ - async reconnectPeersWithPendingChannels() { - const peers = await RnLdk.listPeers(); - const peers2reconnect: Record = {}; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) { - // pending channel - if (!peers.includes(channel.remote_node_id)) peers2reconnect[channel.remote_node_id] = true; - } - } - } - - for (const pubkey of Object.keys(peers2reconnect)) { - const { host, port } = await this.lookupNodeConnectionDetailsByPubkey(pubkey); - await this.connectPeer(pubkey, host, port); - } - } - - async getUserInvoices(limit = false) { - const newInvoices: any[] = []; - let found = false; - - // okay, so the idea is that `this._listInvoices` is a persistant storage of invoices, while - // `RnLdk.receivedPayments` is only a temp storage of emited events - - // we iterate through all stored invoices - for (const invoice of this._listInvoices) { - const newInvoice = Object.assign({}, invoice); - - // iterate through events of received payments - for (const receivedPayment of RnLdk.receivedPayments || []) { - if (receivedPayment.payment_hash === invoice.payment_hash) { - // match! this particular payment was paid - newInvoice.ispaid = true; - newInvoice.value = Math.floor(parseInt(receivedPayment.amt, 10) / 1000); - found = true; - } - } - - newInvoices.push(newInvoice); - } - - // overwrite stored array if flag was set - if (found) this._listInvoices = newInvoices; - - return this._listInvoices; - } - - isInvoiceGeneratedByWallet(paymentRequest: string) { - return Boolean(this?._listInvoices?.some(invoice => invoice.payment_request === paymentRequest)); - } - - weOwnAddress(address: string) { - return false; - } - - async addInvoice(amtSat: number, memo: string) { - if (await this.channelsNeedReestablish()) { - await this.reestablishChannels(); - await this.waitForAtLeastOneChannelBecomeActive(); - } - - if (this.getReceivableBalance() < amtSat) throw new Error('You dont have enough inbound capacity'); - - const paymentRequest = await RnLdk.addInvoice(amtSat * 1000, memo); - if (!paymentRequest) return false; - - const decoded = this.decodeInvoice(paymentRequest); - - this._listInvoices = this._listInvoices || []; - const tx = { - payment_request: paymentRequest, - ispaid: false, - timestamp: +new Date(), - expire_time: 3600 * 1000, - amt: amtSat, - type: 'user_invoice', - payment_hash: decoded.payment_hash, - description: memo || '', - }; - this._listInvoices.push(tx); - - return paymentRequest; - } - - async getAddressAsync() { - throw new Error('getAddressAsync: Not implemented'); - } - - async allowOnchainAddress(): Promise { - throw new Error('allowOnchainAddress: Not implemented'); - } - - getTransactions() { - const ret = []; - - for (const payment of this?._listPayments || []) { - const newTx = Object.assign({}, payment, { - type: 'paid_invoice', - walletID: this.getID(), - }); - ret.push(newTx); - } - - // ############################################ - - for (const invoice of this?._listInvoices || []) { - const tx = { - payment_request: invoice.payment_request, - ispaid: invoice.ispaid, - received: invoice.timestamp, - type: invoice.type, - value: invoice.value || invoice.amt, - memo: invoice.description, - timestamp: invoice.timestamp, // important - expire_time: invoice.expire_time, // important - walletID: this.getID(), - }; - - if (tx.ispaid || invoice.timestamp + invoice.expire_time > +new Date()) { - // expired non-paid invoices are not shown - ret.push(tx); - } - } - - ret.sort(function (a, b) { - return b.received - a.received; - }); - - return ret; - } - - async fetchTransactions() { - if (this.timeToCheckBlockchain()) { - try { - // exception might be in case of incompletely-started LDK - this._listChannels = await RnLdk.listChannels(); - await this.checkBlockchain(); - // ^^^ will be executed if above didnt throw exceptions, which means ldk fully started. - // we need this for a case when app returns from background if it was in bg for a really long time. - // ldk needs to update it's blockchain data, and this is practically the only place where it can - // do that (except on cold start) - } catch (_) {} - } - - try { - await this.reconnectPeersWithPendingChannels(); - } catch (error: any) { - console.log('fetchTransactions failed'); - console.log(error.message); - } - - await this.getUserInvoices(); // it internally updates paid user invoices - } - - getBalance() { - let sum = 0; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) continue; // pending channel - sum += Math.floor(parseInt(channel.outbound_capacity_msat, 10) / 1000); - } - } - - return sum; - } - - getReceivableBalance() { - let sum = 0; - if (this._listChannels) { - for (const channel of this._listChannels) { - if (!channel.is_funding_locked) continue; // pending channel - sum += Math.floor(parseInt(channel.inbound_capacity_msat, 10) / 1000); - } - } - return sum; - } - - /** - * This method checks if there is balance on first unwapped address we have. - * This address is a fallback in case user has _no_ other wallets to withdraw onchain coins to, so closed-channel - * funds land on this address. Ofcourse, if user provided us a withdraw address, it should be stored in - * `this._refundAddressScriptHex` and its balance frankly is not our concern. - * - * @return {Promise<{confirmedBalance: number}>} - */ - async walletBalance() { - let confirmedSat = 0; - if (this._unwrapFirstExternalAddressFromMnemonicsCache) { - const response = await fetch('https://blockstream.info/api/address/' + this._unwrapFirstExternalAddressFromMnemonicsCache + '/utxo'); - const json = await response.json(); - if (json && Array.isArray(json)) { - for (const utxo of json) { - if (utxo?.status?.confirmed) { - confirmedSat += parseInt(utxo.value, 10); - } - } - } - } - - return { confirmedBalance: confirmedSat }; - } - - async fetchBalance() { - await this.listChannels(); // updates channels - } - - async claimCoins(address: string) { - console.log('unwrapping wif...'); - const wif = this.unwrapFirstExternalWIFFromMnemonics(); - const wallet = new SegwitBech32Wallet(); - wallet.setSecret(String(wif)); - console.log('fetching balance...'); - await wallet.fetchUtxo(); - console.log(wallet.getBalance(), wallet.getUtxo()); - console.log('creating transation...'); - const { tx } = wallet.createTransaction(wallet.getUtxo(), [{ address }], 2, address, 0, false, 0); - if (!tx) throw new Error('claimCoins: could not create transaction'); - console.log('broadcasting...'); - return await wallet.broadcastTx(tx.toHex()); - } - - async fetchInfo() { - throw new Error('fetchInfo: Not implemented'); - } - - allowReceive() { - return true; - } - - async closeChannel(fundingTxidHex: string, force = false) { - return force ? await RnLdk.closeChannelForce(fundingTxidHex) : await RnLdk.closeChannelCooperatively(fundingTxidHex); - } - - getLatestTransactionTime(): string | 0 { - if (this.getTransactions().length === 0) { - return 0; - } - let max = -1; - for (const tx of this.getTransactions()) { - if (tx.received) max = Math.max(tx.received, max); - } - return new Date(max).toString(); - } - - async getLogs() { - return RnLdk.getLogs() - .map(log => log.line) - .join('\n'); - } - - async getLogsWithTs() { - return RnLdk.getLogs() - .map(log => log.ts + ' ' + log.line) - .join('\n'); - } - - async fetchPendingTransactions() {} - - async fetchUserInvoices() { - await this.getUserInvoices(); - } - - static preimage2hash(preimageHex: string): string { - const hash = bitcoin.crypto.sha256(Buffer.from(preimageHex, 'hex')); - return hash.toString('hex'); - } - - async reestablishChannels() { - const connectedInThisRun: any = {}; - for (const channel of await this.listChannels()) { - if (channel.is_usable) continue; // already connected..? - if (connectedInThisRun[channel.remote_node_id]) continue; // already tried to reconnect (in case there are several channels with the same node) - const { pubkey, host, port } = await this.lookupNodeConnectionDetailsByPubkey(channel.remote_node_id); - await this.connectPeer(pubkey, host, port); - connectedInThisRun[pubkey] = true; - } - } - - async channelsNeedReestablish() { - const freshListChannels = await this.listChannels(); - const active = freshListChannels.filter(chan => !!chan.is_usable && chan.is_funding_locked).length; - return freshListChannels.length !== +active; - } - - async waitForAtLeastOneChannelBecomeActive() { - const active = (await this.listChannels()).filter(chan => !!chan.is_usable).length; - - for (let c = 0; c < 10; c++) { - await new Promise(resolve => setTimeout(resolve, 500)); // sleep - const freshListChannels = await this.listChannels(); - const active2 = freshListChannels.filter(chan => !!chan.is_usable).length; - if (freshListChannels.length === +active2) return true; // all active kek - - if (freshListChannels.length === 0) return true; // no channels at all - if (+active2 > +active) return true; // something became active, lets ret - } - - return false; - } - - async setRefundAddress(address: string) { - const script = bitcoin.address.toOutputScript(address); - this._refundAddressScriptHex = script.toString('hex'); - await RnLdk.setRefundAddressScript(this._refundAddressScriptHex); - } - - static async getVersion() { - return RnLdk.getVersion(); - } - - static getPackageVersion() { - return RnLdk.getPackageVersion(); - } - - getChannelsClosedEvents() { - return RnLdk.channelsClosed; - } - - async purgeLocalStorage() { - return RnLdk.getStorage().purgeLocalStorage(); - } - - /** - * executes async function in background, so calling code can return immediately, while catching all thrown exceptions - * and showing them in alert() instead of propagating them up - * - * @param func {function} Async functino to execute - * @private - */ - _execInBackground(func: () => void) { - const that = this; - (async () => { - try { - await func.call(that); - } catch (error: any) { - alert('_execInBackground error:' + error.message); - } - })(); - } -} diff --git a/class/wallets/multisig-hd-wallet.js b/class/wallets/multisig-hd-wallet.js deleted file mode 100644 index 88506d73d3d..00000000000 --- a/class/wallets/multisig-hd-wallet.js +++ /dev/null @@ -1,1197 +0,0 @@ -import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; -import * as bip39 from 'bip39'; -import b58 from 'bs58check'; -import { decodeUR } from '../../blue_modules/ur'; -import { ECPairFactory } from 'ecpair'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; -const ECPair = ECPairFactory(ecc); -const BlueElectrum = require('../../blue_modules/BlueElectrum'); -const bip32 = BIP32Factory(ecc); -const bitcoin = require('bitcoinjs-lib'); -const createHash = require('create-hash'); -const reverse = require('buffer-reverse'); -const mn = require('electrum-mnemonic'); - -const electrumSegwit = passphrase => ({ - prefix: mn.PREFIXES.segwit, - ...(passphrase ? { passphrase } : {}), -}); - -const electrumStandart = passphrase => ({ - prefix: mn.PREFIXES.standard, - ...(passphrase ? { passphrase } : {}), -}); - -const ELECTRUM_SEED_PREFIX = 'electrumseed:'; - -export class MultisigHDWallet extends AbstractHDElectrumWallet { - static type = 'HDmultisig'; - static typeReadable = 'Multisig Vault'; - - static FORMAT_P2WSH = 'p2wsh'; - static FORMAT_P2SH_P2WSH = 'p2sh-p2wsh'; - static FORMAT_P2SH_P2WSH_ALT = 'p2wsh-p2sh'; - static FORMAT_P2SH = 'p2sh'; - - static PATH_NATIVE_SEGWIT = "m/48'/0'/0'/2'"; - static PATH_WRAPPED_SEGWIT = "m/48'/0'/0'/1'"; - static PATH_LEGACY = "m/45'"; - - constructor() { - super(); - this._m = 0; // minimum required signatures so spend (m out of n) - this._cosigners = []; // array of xpubs or mnemonic seeds - this._cosignersFingerprints = []; // array of according fingerprints (if any provided) - this._cosignersCustomPaths = []; // array of according paths (if any provided) - this._cosignersPassphrases = []; // array of according passphrases (if any provided) - this._derivationPath = ''; - this._isNativeSegwit = false; - this._isWrappedSegwit = false; - this._isLegacy = false; - this.gap_limit = 10; - } - - isLegacy() { - return this._isLegacy; - } - - isNativeSegwit() { - return this._isNativeSegwit; - } - - isWrappedSegwit() { - return this._isWrappedSegwit; - } - - setWrappedSegwit() { - this._isWrappedSegwit = true; - } - - setNativeSegwit() { - this._isNativeSegwit = true; - } - - setLegacy() { - this._isLegacy = true; - } - - setM(m) { - this._m = m; - } - - /** - * @returns {number} How many minumim signatures required to authorize a spend - */ - getM() { - return this._m; - } - - /** - * @returns {number} Total count of cosigners - */ - getN() { - return this._cosigners.length; - } - - setDerivationPath(path) { - this._derivationPath = path; - switch (this._derivationPath) { - case "m/48'/0'/0'/2'": - this._isNativeSegwit = true; - break; - case "m/48'/0'/0'/1'": - this._isWrappedSegwit = true; - break; - case "m/45'": - this._isLegacy = true; - break; - case "m/44'": - this._isLegacy = true; - break; - } - } - - getCustomDerivationPathForCosigner(index) { - if (index === 0) throw new Error('cosigners indexation starts from 1'); - if (index > this.getN()) return false; - return this._cosignersCustomPaths[index - 1] || this.getDerivationPath(); - } - - getCosigner(index) { - if (index === 0) throw new Error('cosigners indexation starts from 1'); - return this._cosigners[index - 1]; - } - - getFingerprint(index) { - if (index === 0) throw new Error('cosigners fingerprints indexation starts from 1'); - return this._cosignersFingerprints[index - 1]; - } - - getCosignerForFingerprint(fp) { - const index = this._cosignersFingerprints.indexOf(fp); - return this._cosigners[index]; - } - - getPassphrase(index) { - if (index === 0) throw new Error('cosigners indexation starts from 1'); - return this._cosignersPassphrases[index - 1]; - } - - static isXpubValid(key) { - let xpub; - - try { - const tempWallet = new MultisigHDWallet(); - xpub = tempWallet._zpubToXpub(key); - bip32.fromBase58(xpub); - return true; - } catch (_) {} - - return false; - } - - static isXprvValid(xprv) { - try { - xprv = MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv); - bip32.fromBase58(xprv); - return true; - } catch (_) { - return false; - } - } - - /** - * - * @param key {string} Either xpub or mnemonic phrase - * @param fingerprint {string} Fingerprint for cosigner that is added as xpub - * @param path {string} Custom path (if any) for cosigner that is added as mnemonics - * @param passphrase {string} BIP38 Passphrase (if any) - */ - addCosigner(key, fingerprint, path, passphrase) { - if (MultisigHDWallet.isXpubString(key) && !fingerprint) { - throw new Error('fingerprint is required when adding cosigner as xpub (watch-only)'); - } - - if (path && !this.constructor.isPathValid(path)) { - throw new Error('path is not valid'); - } - - if (MultisigHDWallet.isXprvString(key)) { - // nop, but probably should validate xprv - } else if (MultisigHDWallet.isXpubString(key)) { - // nop, just validate - if (!MultisigHDWallet.isXpubValid(key)) throw new Error('Not a valid xpub: ' + key); - } else if (key.startsWith(ELECTRUM_SEED_PREFIX) && fingerprint && path) { - // its an electrum seed - const mnemonic = key.replace(ELECTRUM_SEED_PREFIX, ''); - try { - mn.mnemonicToSeedSync(mnemonic, electrumStandart(passphrase)); - this.setLegacy(); - } catch (_) { - try { - mn.mnemonicToSeedSync(mnemonic, electrumSegwit(passphrase)); - this.setNativeSegwit(); - } catch (__) { - throw new Error('Not a valid electrum seed'); - } - } - } else { - // mnemonics. lets derive fingerprint (if it wasnt provided) - if (!bip39.validateMnemonic(key)) throw new Error('Not a valid mnemonic phrase'); - fingerprint = fingerprint || MultisigHDWallet.mnemonicToFingerprint(key, passphrase); - } - - if (fingerprint && this._cosignersFingerprints.indexOf(fingerprint.toUpperCase()) !== -1 && fingerprint !== '00000000') { - // 00000000 is a special case, means we have no idea what the FP is but its okay - throw new Error('Duplicate fingerprint'); - } - - const index = this._cosigners.length; - this._cosigners[index] = key; - if (fingerprint) this._cosignersFingerprints[index] = fingerprint.toUpperCase(); - if (path) this._cosignersCustomPaths[index] = path; - if (passphrase) this._cosignersPassphrases[index] = passphrase; - } - - static convertMultisigXprvToRegularXprv(Zprv) { - let data = b58.decode(Zprv); - data = data.slice(4); - return b58.encode(Buffer.concat([Buffer.from('0488ade4', 'hex'), data])); - } - - static convertXprvToXpub(xprv) { - const restored = bip32.fromBase58(MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv)); - return restored.neutered().toBase58(); - } - - /** - * Stored cosigner can be EITHER xpub (or Zpub or smth), OR mnemonic phrase. This method converts it to xpub - * - * @param cosigner {string} Zpub (or similar) or mnemonic seed - * @returns {string} xpub - * @private - */ - _getXpubFromCosigner(cosigner) { - if (MultisigHDWallet.isXprvString(cosigner)) cosigner = MultisigHDWallet.convertXprvToXpub(cosigner); - let xpub = cosigner; - if (!MultisigHDWallet.isXpubString(cosigner)) { - const index = this._cosigners.indexOf(cosigner); - xpub = MultisigHDWallet.seedToXpub( - cosigner, - this._cosignersCustomPaths[index] || this._derivationPath, - this._cosignersPassphrases[index], - ); - } - return this._zpubToXpub(xpub); - } - - _getExternalAddressByIndex(index) { - if (!this._m) throw new Error('m is not set'); - index = +index; - if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit - - const address = this._getAddressFromNode(0, index); - this.external_addresses_cache[index] = address; - return address; - } - - _getAddressFromNode(nodeIndex, index) { - const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - this._nodes = this._nodes || []; - this._nodes[nodeIndex] = this._nodes[nodeIndex] || []; - let _node; - - if (!this._nodes[nodeIndex][cosignerIndex]) { - const xpub = this._getXpubFromCosigner(cosigner); - const hdNode = bip32.fromBase58(xpub); - _node = hdNode.derive(nodeIndex); - this._nodes[nodeIndex][cosignerIndex] = _node; - } else { - _node = this._nodes[nodeIndex][cosignerIndex]; - } - - pubkeys.push(_node.derive(index).publicKey); - } - - if (this.isWrappedSegwit()) { - const { address } = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }), - }); - - return address; - } else if (this.isNativeSegwit()) { - const { address } = bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }); - - return address; - } else if (this.isLegacy()) { - const { address } = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }); - - return address; - } else { - throw new Error('Dont know how to make address'); - } - } - - _getInternalAddressByIndex(index) { - if (!this._m) throw new Error('m is not set'); - index = +index; - if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit - - const address = this._getAddressFromNode(1, index); - this.internal_addresses_cache[index] = address; - return address; - } - - static seedToXpub(mnemonic, path, passphrase) { - let seed; - if (mnemonic.startsWith(ELECTRUM_SEED_PREFIX)) { - seed = MultisigHDWallet.convertElectrumMnemonicToSeed(mnemonic, passphrase); - } else { - seed = bip39.mnemonicToSeedSync(mnemonic, passphrase); - } - - const root = bip32.fromSeed(seed); - const child = root.derivePath(path).neutered(); - return child.toBase58(); - } - - /** - * Returns xpub with correct prefix accodting to this objects set derivation path, for example 'Zpub' (with - * capital Z) for bech32 multisig - * @see https://github.com/satoshilabs/slips/blob/master/slip-0132.md - * - * @param xpub {string} Any kind of xpub, including zpub etc since we are only swapping the prefix bytes - * @returns {string} - */ - convertXpubToMultisignatureXpub(xpub) { - let data = b58.decode(xpub); - data = data.slice(4); - if (this.isNativeSegwit()) { - return b58.encode(Buffer.concat([Buffer.from('02aa7ed3', 'hex'), data])); - } else if (this.isWrappedSegwit()) { - return b58.encode(Buffer.concat([Buffer.from('0295b43f', 'hex'), data])); - } - - return xpub; - } - - convertXprvToMultisignatureXprv(xpub) { - let data = b58.decode(xpub); - data = data.slice(4); - if (this.isNativeSegwit()) { - return b58.encode(Buffer.concat([Buffer.from('02aa7a99', 'hex'), data])); - } else if (this.isWrappedSegwit()) { - return b58.encode(Buffer.concat([Buffer.from('0295b005', 'hex'), data])); - } - - return xpub; - } - - static isXpubString(xpub) { - return ['xpub', 'ypub', 'zpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); - } - - static isXprvString(xpub) { - return ['xprv', 'yprv', 'zprv', 'Yprv', 'Zprv'].includes(xpub.substring(0, 4)); - } - - /** - * Converts fingerprint that is stored as a deciman number to hex string (all caps) - * - * @param xfp {number} For example 64392470 - * @returns {string} For example 168DD603 - */ - static ckccXfp2fingerprint(xfp) { - let masterFingerprintHex = Number(xfp).toString(16); - while (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte - - // poor man's little-endian conversion: - // ¯\_(ツ)_/¯ - return ( - masterFingerprintHex[6] + - masterFingerprintHex[7] + - masterFingerprintHex[4] + - masterFingerprintHex[5] + - masterFingerprintHex[2] + - masterFingerprintHex[3] + - masterFingerprintHex[0] + - masterFingerprintHex[1] - ).toUpperCase(); - } - - getXpub() { - return this.getSecret(true); - } - - getSecret(coordinationSetup = false) { - let ret = '# BlueWallet Multisig setup file\n'; - if (coordinationSetup) ret += '# this file contains only public keys and is safe to\n# distribute among cosigners\n'; - if (!coordinationSetup) ret += '# this file may contain private information\n'; - ret += '#\n'; - ret += 'Name: ' + this.getLabel() + '\n'; - ret += 'Policy: ' + this.getM() + ' of ' + this.getN() + '\n'; - - let hasCustomPaths = 0; - const customPaths = {}; - for (let index = 0; index < this.getN(); index++) { - if (this._cosignersCustomPaths[index]) hasCustomPaths++; - if (this._cosignersCustomPaths[index]) customPaths[this._cosignersCustomPaths[index]] = 1; - } - - let printedGlobalDerivation = false; - - if (this.getDerivationPath()) customPaths[this.getDerivationPath()] = 1; - if (Object.keys(customPaths).length === 1) { - // we have exactly one path, for everyone. lets just print it - for (const path of Object.keys(customPaths)) { - ret += 'Derivation: ' + path + '\n'; - printedGlobalDerivation = true; - } - } - - if (hasCustomPaths !== this.getN() && !printedGlobalDerivation) { - printedGlobalDerivation = true; - ret += 'Derivation: ' + this.getDerivationPath() + '\n'; - } - - if (this.isNativeSegwit()) { - ret += 'Format: P2WSH\n'; - } else if (this.isWrappedSegwit()) { - ret += 'Format: P2SH-P2WSH\n'; - } else if (this.isLegacy()) { - ret += 'Format: P2SH\n'; - } else { - ret += 'Format: unknown\n'; - } - ret += '\n'; - - for (let index = 0; index < this.getN(); index++) { - if ( - this._cosignersCustomPaths[index] && - ((printedGlobalDerivation && this._cosignersCustomPaths[index] !== this.getDerivationPath()) || !printedGlobalDerivation) - ) { - ret += '# derivation: ' + this._cosignersCustomPaths[index] + '\n'; - // if we printed global derivation and this cosigned _has_ derivation and its different from global - we print it ; - // or we print it if cosigner _has_ some derivation set and we did not print global - } - if (this.constructor.isXpubString(this._cosigners[index])) { - ret += this._cosignersFingerprints[index] + ': ' + this._cosigners[index] + '\n'; - } else { - if (coordinationSetup) { - const xpub = this.convertXpubToMultisignatureXpub( - MultisigHDWallet.seedToXpub( - this._cosigners[index], - this._cosignersCustomPaths[index] || this._derivationPath, - this._cosignersPassphrases[index], - ), - ); - const fingerprint = MultisigHDWallet.mnemonicToFingerprint(this._cosigners[index], this._cosignersPassphrases[index]); - ret += fingerprint + ': ' + xpub + '\n'; - } else { - ret += 'seed: ' + this._cosigners[index]; - if (this._cosignersPassphrases[index]) ret += ' - ' + this._cosignersPassphrases[index]; - ret += '\n# warning! sensitive information, do not disclose ^^^ \n'; - } - } - - ret += '\n'; - } - - return ret; - } - - setSecret(secret) { - if (secret.toUpperCase().startsWith('UR:BYTES')) { - const decoded = decodeUR([secret]); - const b = Buffer.from(decoded, 'hex'); - secret = b.toString(); - } - - // is it Coldcard json file? - let json; - try { - json = JSON.parse(secret); - } catch (_) {} - if (json && json.xfp && json.p2wsh_deriv && json.p2wsh) { - this.addCosigner(json.p2wsh, json.xfp); // technically we dont need deriv (json.p2wsh_deriv), since cosigner is already an xpub - return; - } - - // is it electrum json? - if (json && json.wallet_type && json.wallet_type !== 'standard') { - const mofn = json.wallet_type.split('of'); - this.setM(parseInt(mofn[0].trim(), 10)); - const n = parseInt(mofn[1].trim(), 10); - for (let c = 1; c <= n; c++) { - const cosignerData = json['x' + c + '/']; - if (cosignerData) { - const fingerprint = - (cosignerData.ckcc_xfp - ? MultisigHDWallet.ckccXfp2fingerprint(cosignerData.ckcc_xfp) - : cosignerData.root_fingerprint?.toUpperCase()) || '00000000'; - if (cosignerData.seed) { - this.addCosigner(ELECTRUM_SEED_PREFIX + cosignerData.seed, fingerprint, cosignerData.derivation, cosignerData.passphrase); - } else if (cosignerData.xprv && MultisigHDWallet.isXprvValid(cosignerData.xprv)) { - this.addCosigner(cosignerData.xprv, fingerprint, cosignerData.derivation); - } else { - this.addCosigner(cosignerData.xpub, fingerprint, cosignerData.derivation); - } - } - - if (cosignerData?.xpub?.startsWith('Zpub')) this.setNativeSegwit(); - if (cosignerData?.xpub?.startsWith('Ypub')) this.setWrappedSegwit(); - if (cosignerData?.xpub?.startsWith('xpub')) this.setLegacy(); - } - } - - // coldcard & cobo txt format: - let customPathForCurrentCosigner = false; - for (const line of secret.split('\n')) { - const [key, value] = line.split(':'); - - switch (key) { - case 'Name': - this.setLabel(value.trim()); - break; - - case 'Policy': - this.setM(parseInt(value.trim().split('of')[0].trim(), 10)); - break; - - case 'Derivation': - this.setDerivationPath(value.trim()); - break; - - case 'Format': - switch (value.trim()) { - case MultisigHDWallet.FORMAT_P2WSH.toUpperCase(): - this.setNativeSegwit(); - break; - case MultisigHDWallet.FORMAT_P2SH_P2WSH.toUpperCase(): - case MultisigHDWallet.FORMAT_P2SH_P2WSH_ALT.toUpperCase(): - this.setWrappedSegwit(); - break; - case MultisigHDWallet.FORMAT_P2SH.toUpperCase(): - this.setLegacy(); - break; - } - break; - - default: - if (key && value && MultisigHDWallet.isXpubString(value.trim())) { - this.addCosigner(value.trim(), key, customPathForCurrentCosigner); - } else if (key.replace('#', '').trim() === 'derivation') { - customPathForCurrentCosigner = value.trim(); - } else if (key === 'seed') { - const [seed, passphrase] = value.split(' - '); - this.addCosigner(seed.trim(), false, customPathForCurrentCosigner, passphrase); - } - break; - } - } - - // is it wallet descriptor? - // @see https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md - // @see https://github.com/Fonta1n3/FullyNoded/blob/master/Docs/Wallets/Wallet-Export-Spec.md - if (!json && secret.indexOf('sortedmulti(')) { - // provided secret was NOT json but plain wallet descriptor text. lets mock json - json = { descriptor: secret, label: 'Multisig vault' }; - } - if (secret.indexOf('sortedmulti(') !== -1 && json.descriptor) { - if (json.label) this.setLabel(json.label); - if (json.descriptor.includes('sh(wsh(')) { - this.setWrappedSegwit(); - } else if (json.descriptor.includes('wsh(')) { - this.setNativeSegwit(); - } else if (json.descriptor.includes('sh(')) { - this.setLegacy(); - } - - const s2 = json.descriptor.substr(json.descriptor.indexOf('sortedmulti(') + 12); - const s3 = s2.split(','); - const M = parseInt(s3[0], 10); - if (M) this.setM(M); - - for (let c = 1; c < s3.length; c++) { - const re = /\[([^\]]+)\](.*)/; - const m = s3[c].match(re); - if (m && m.length === 3) { - let hexFingerprint = m[1].split('/')[0]; - if (hexFingerprint.length === 8) { - hexFingerprint = Buffer.from(hexFingerprint, 'hex').toString('hex'); - } - - const path = 'm/' + m[1].split('/').slice(1).join('/').replace(/[h]/g, "'"); - let xpub = m[2]; - if (xpub.indexOf('/') !== -1) { - xpub = xpub.substr(0, xpub.indexOf('/')); - } - if (xpub.indexOf(')') !== -1) { - xpub = xpub.substr(0, xpub.indexOf(')')); - } - - this.addCosigner(xpub, hexFingerprint.toUpperCase(), path); - } - } - - if (this.getN() === 0) { - // handling a case when smth went wrong and we didnt parse any cosigners, probably because - // string is a bit non-standard, deesnt have chars like '[' - for (let c = 1; c < s3.length; c++) { - const hexFingerprint = s3[c].split('/')[0]; - let indexOfXpub = s3[c].indexOf('xpub'); - if (indexOfXpub === -1) { - // just for any case - indexOfXpub = s3[c].indexOf('ypub'); - } - if (indexOfXpub === -1) { - // just for any case - indexOfXpub = s3[c].indexOf('zpub'); - } - if (indexOfXpub === -1) { - throw new Error('Could not parse cosigner in a descriptor'); - } - - const xpub = s3[c].substring(indexOfXpub).replaceAll(')', ''); - const path = 'm' + s3[c].substring(hexFingerprint.length, indexOfXpub); - - this.addCosigner(xpub, hexFingerprint.toUpperCase(), path); - } - } - } - - // is it caravan? - if (json && json.network === 'mainnet' && json.quorum) { - this.setM(+json.quorum.requiredSigners); - if (json.name) this.setLabel(json.name); - - switch (json.addressType.toLowerCase()) { - case MultisigHDWallet.FORMAT_P2SH: - this.setLegacy(); - break; - case MultisigHDWallet.FORMAT_P2SH_P2WSH: - case MultisigHDWallet.FORMAT_P2SH_P2WSH_ALT: - this.setWrappedSegwit(); - break; - case MultisigHDWallet.FORMAT_P2WSH: - default: - this.setNativeSegwit(); - break; - } - - for (const pk of json.extendedPublicKeys) { - const path = this.constructor.isPathValid(json.bip32Path) ? json.bip32Path : "m/1'"; - this.addCosigner(pk.xpub, pk.xfp ?? '00000000', path); - } - } - - if (!this.getLabel()) this.setLabel('Multisig vault'); - } - - _getDerivationPathByAddressWithCustomPath(address, customPathPrefix) { - const path = customPathPrefix || this._derivationPath; - for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { - if (this._getExternalAddressByIndex(c) === address) return path + '/0/' + c; - } - for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { - if (this._getInternalAddressByIndex(c) === address) return path + '/1/' + c; - } - - return false; - } - - _getWifForAddress(address) { - return false; - } - - _getPubkeyByAddress(address) { - throw new Error('Not applicable in multisig'); - } - - _getDerivationPathByAddress(address) { - throw new Error('Not applicable in multisig'); - } - - _addPsbtInput(psbt, input, sequence, masterFingerprintBuffer) { - const bip32Derivation = []; // array per each pubkey thats gona be used - const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - const path = this._getDerivationPathByAddressWithCustomPath( - input.address, - this._cosignersCustomPaths[cosignerIndex] || this._derivationPath, - ); - // ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used - const masterFingerprint = Buffer.from(this._cosignersFingerprints[cosignerIndex], 'hex'); - - const xpub = this._getXpubFromCosigner(cosigner); - const hdNode0 = bip32.fromBase58(xpub); - const splt = path.split('/'); - const internal = +splt[splt.length - 2]; - const index = +splt[splt.length - 1]; - const _node0 = hdNode0.derive(internal); - const pubkey = _node0.derive(index).publicKey; - pubkeys.push(pubkey); - - bip32Derivation.push({ - masterFingerprint, - path, - pubkey, - }); - } - - if (this.isNativeSegwit()) { - const p2wsh = bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }); - const witnessScript = p2wsh.redeem.output; - - if (!input.txhex) throw new Error('Electrum server didnt provide txhex to properly create PSBT transaction'); - - psbt.addInput({ - hash: input.txId, - index: input.vout, - sequence, - bip32Derivation, - witnessUtxo: { - script: p2wsh.output, - value: input.value, - }, - witnessScript, - // hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate - // some hw wallets attack vector - nonWitnessUtxo: Buffer.from(input.txhex, 'hex'), - }); - } else if (this.isWrappedSegwit()) { - const p2shP2wsh = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }), - }); - const witnessScript = p2shP2wsh.redeem.redeem.output; - const redeemScript = p2shP2wsh.redeem.output; - - psbt.addInput({ - hash: input.txId, - index: input.vout, - sequence, - bip32Derivation, - witnessUtxo: { - script: p2shP2wsh.output, - value: input.value, - }, - witnessScript, - redeemScript, - // hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate - // some hw wallets attack vector - nonWitnessUtxo: Buffer.from(input.txhex, 'hex'), - }); - } else if (this.isLegacy()) { - const p2sh = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }); - const redeemScript = p2sh.redeem.output; - psbt.addInput({ - hash: input.txId, - index: input.vout, - sequence, - bip32Derivation, - redeemScript, - nonWitnessUtxo: Buffer.from(input.txhex, 'hex'), - }); - } else { - throw new Error('Dont know how to add input'); - } - - return psbt; - } - - _getOutputDataForChange(outputData) { - const bip32Derivation = []; // array per each pubkey thats gona be used - const pubkeys = []; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - const path = this._getDerivationPathByAddressWithCustomPath( - outputData.address, - this._cosignersCustomPaths[cosignerIndex] || this._derivationPath, - ); - // ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used - const masterFingerprint = Buffer.from(this._cosignersFingerprints[cosignerIndex], 'hex'); - - const xpub = this._getXpubFromCosigner(cosigner); - const hdNode0 = bip32.fromBase58(xpub); - const splt = path.split('/'); - const internal = +splt[splt.length - 2]; - const index = +splt[splt.length - 1]; - const _node0 = hdNode0.derive(internal); - const pubkey = _node0.derive(index).publicKey; - pubkeys.push(pubkey); - - bip32Derivation.push({ - masterFingerprint, - path, - pubkey, - }); - } - - outputData.bip32Derivation = bip32Derivation; - - if (this.isLegacy()) { - const p2sh = bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }); - outputData.redeemScript = p2sh.output; - } else if (this.isWrappedSegwit()) { - const p2shP2wsh = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }), - }); - outputData.witnessScript = p2shP2wsh.redeem.redeem.output; - outputData.redeemScript = p2shP2wsh.redeem.output; - } else if (this.isNativeSegwit()) { - // not needed by coldcard, apparently..? - const p2wsh = bitcoin.payments.p2wsh({ - redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), - }); - outputData.witnessScript = p2wsh.redeem.output; - } else { - throw new Error('dont know how to add change output'); - } - - return outputData; - } - - howManySignaturesCanWeMake() { - let howManyPrivKeysWeGot = 0; - for (const cosigner of this._cosigners) { - if (!MultisigHDWallet.isXpubString(cosigner) && !MultisigHDWallet.isXprvString(cosigner)) howManyPrivKeysWeGot++; - } - - return howManyPrivKeysWeGot; - } - - /** - * @inheritDoc - */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { - if (targets.length === 0) throw new Error('No destination provided'); - if (this.howManySignaturesCanWeMake() === 0) skipSigning = true; - - // overriding script length for proper vbytes calculation - for (const u of utxos) { - u.script = u.script || {}; - if (this.isNativeSegwit()) { - u.script.length = u.script.length || Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4); - } else if (this.isWrappedSegwit()) { - u.script.length = u.script.length || 35 + Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4); - } else { - u.script.length = u.script.length || 9 + this.getM() * 74 + this.getN() * 34; - } - } - - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); - sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; - - let psbt = new bitcoin.Psbt(); - - let c = 0; - inputs.forEach(input => { - c++; - psbt = this._addPsbtInput(psbt, input, sequence); - }); - - outputs.forEach(output => { - // if output has no address - this is change output - let change = false; - if (!output.address) { - change = true; - output.address = changeAddress; - } - - let outputData = { - address: output.address, - value: output.value, - }; - - if (change) { - outputData = this._getOutputDataForChange(outputData); - } - - psbt.addOutput(outputData); - }); - - if (!skipSigning) { - for (let cc = 0; cc < c; cc++) { - let signaturesMade = 0; - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - if (MultisigHDWallet.isXpubString(cosigner)) continue; - // ok this is a mnemonic, lets try to sign - if (signaturesMade >= this.getM()) { - // dont sign more than we need, otherwise there will be "Too many signatures" error - continue; - } - const passphrase = this._cosignersPassphrases[cosignerIndex]; - let seed = bip39.mnemonicToSeedSync(cosigner, passphrase); - if (cosigner.startsWith(ELECTRUM_SEED_PREFIX)) { - seed = MultisigHDWallet.convertElectrumMnemonicToSeed(cosigner, passphrase); - } - - const hdRoot = bip32.fromSeed(seed); - psbt.signInputHD(cc, hdRoot); - signaturesMade++; - } - } - } - - let tx; - if (!skipSigning && this.howManySignaturesCanWeMake() >= this.getM()) { - tx = psbt.finalizeAllInputs().extractTransaction(); - } - return { tx, inputs, outputs, fee, psbt }; - } - - static convertElectrumMnemonicToSeed(cosigner, passphrase) { - let seed; - try { - seed = mn.mnemonicToSeedSync(cosigner.replace(ELECTRUM_SEED_PREFIX, ''), electrumSegwit(passphrase)); - } catch (_) { - try { - seed = mn.mnemonicToSeedSync(cosigner.replace(ELECTRUM_SEED_PREFIX, ''), electrumStandart(passphrase)); - } catch (__) { - throw new Error('Not a valid electrum mnemonic'); - } - } - return seed; - } - - /** - * @see https://github.com/bitcoin/bips/blob/master/bip-0067.mediawiki - * - * @param bufArr {Array.} - * @returns {Array.} - */ - static sortBuffers(bufArr) { - return bufArr.sort(Buffer.compare); - } - - prepareForSerialization() { - // deleting structures that cant be serialized - delete this._nodes; - } - - static isPathValid(path) { - const root = bip32.fromSeed(Buffer.alloc(32)); - try { - root.derivePath(path); - return true; - } catch (_) {} - return false; - } - - allowSend() { - return true; - } - - allowSignVerifyMessage() { - return false; - } - - async fetchUtxo() { - await super.fetchUtxo(); - // now we need to fetch txhash for each input as required by PSBT - const txhexes = await BlueElectrum.multiGetTransactionByTxid( - this.getUtxo(true).map(x => x.txid), - 50, - false, - ); - - const newUtxos = []; - for (const u of this.getUtxo(true)) { - if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; - newUtxos.push(u); - } - - this._utxo = newUtxos; - } - - getID() { - const string2hash = [...this._cosigners].sort().join(',') + ';' + [...this._cosignersFingerprints].sort().join(','); - return createHash('sha256').update(string2hash).digest().toString('hex'); - } - - calculateFeeFromPsbt(psbt) { - let goesIn = 0; - const cacheUtxoAmounts = {}; - for (const inp of psbt.data.inputs) { - if (inp.witnessUtxo && inp.witnessUtxo.value) { - // segwit input - goesIn += inp.witnessUtxo.value; - } else if (inp.nonWitnessUtxo) { - // non-segwit input - // lets parse this transaction and cache how much each input was worth - const inputTx = bitcoin.Transaction.fromHex(inp.nonWitnessUtxo); - let index = 0; - for (const out of inputTx.outs) { - cacheUtxoAmounts[inputTx.getId() + ':' + index] = out.value; - index++; - } - } - } - - if (goesIn === 0) { - // means we failed to get amounts that go in previously, so lets use utxo amounts cache we've build - // from non-segwit inputs - for (const inp of psbt.txInputs) { - const cacheKey = reverse(inp.hash).toString('hex') + ':' + inp.index; - if (cacheUtxoAmounts[cacheKey]) goesIn += cacheUtxoAmounts[cacheKey]; - } - } - - let goesOut = 0; - for (const output of psbt.txOutputs) { - goesOut += output.value; - } - - return goesIn - goesOut; - } - - calculateHowManySignaturesWeHaveFromPsbt(psbt) { - let sigsHave = 0; - for (const inp of psbt.data.inputs) { - sigsHave = Math.max(sigsHave, inp.partialSig?.length || 0); - if (inp.finalScriptSig || inp.finalScriptWitness) sigsHave = this.getM(); // hacky, but it means we have enough - // He who knows that enough is enough will always have enough. Lao Tzu - } - - return sigsHave; - } - - /** - * Tries to signs passed psbt object (by reference). If there are enough signatures - tries to finalize psbt - * and returns Transaction (ready to extract hex) - * - * @param psbt {Psbt} - * @returns {{ tx: Transaction }} - */ - cosignPsbt(psbt) { - for (let cc = 0; cc < psbt.inputCount; cc++) { - for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { - if (MultisigHDWallet.isXpubString(cosigner)) continue; - - let hdRoot; - if (MultisigHDWallet.isXprvString(cosigner)) { - const xprv = MultisigHDWallet.convertMultisigXprvToRegularXprv(cosigner); - hdRoot = bip32.fromBase58(xprv); - } else { - const passphrase = this._cosignersPassphrases[cosignerIndex]; - const seed = cosigner.startsWith(ELECTRUM_SEED_PREFIX) - ? MultisigHDWallet.convertElectrumMnemonicToSeed(cosigner, passphrase) - : bip39.mnemonicToSeedSync(cosigner, passphrase); - hdRoot = bip32.fromSeed(seed); - } - - try { - psbt.signInputHD(cc, hdRoot); - } catch (_) {} // protects agains duplicate cosignings - - if (!psbt.inputHasHDKey(cc, hdRoot)) { - // failed signing as HD. probably bitcoinjs-lib could not match provided hdRoot's - // fingerprint (or path?) to the ones in psbt, which is the case of stupid Electrum desktop which can - // put bullshit paths and fingerprints in created psbt. - // lets try to find correct priv key and sign manually. - for (const derivation of psbt.data.inputs[cc].bip32Derivation || []) { - // okay, here we assume that fingerprint is irrelevant, but ending of the path is somewhat correct and - // correctly points to `/internal/index`, so we extract pubkey from our stored mnemonics+path and - // match it to the one provided in PSBT's input, and if we have a match - we are in luck! we can sign - // with this private key. - const splt = derivation.path.split('/'); - const internal = +splt[splt.length - 2]; - const index = +splt[splt.length - 1]; - - const path = - hdRoot.depth === 0 - ? this.getCustomDerivationPathForCosigner(cosignerIndex + 1) + `/${internal ? 1 : 0}/${index}` - : `${internal ? 1 : 0}/${index}`; - // ^^^ we assume that counterparty has Zpub for specified derivation path - // if hdRoot.depth !== 0 than this hdnode was recovered from xprv and it already has been set to root path - const child = hdRoot.derivePath(path); - if (psbt.inputHasPubkey(cc, child.publicKey)) { - const keyPair = ECPair.fromPrivateKey(child.privateKey); - try { - psbt.signInput(cc, keyPair); - } catch (_) {} - } - } - } - } - } - - let tx = false; - if (this.calculateHowManySignaturesWeHaveFromPsbt(psbt) >= this.getM()) { - tx = psbt.finalizeAllInputs().extractTransaction(); - } - - return { tx }; - } - - /** - * Looks up xpub cosigner by index, and repalces it with seed + passphrase - * - * @param externalIndex {number} - * @param mnemonic {string} - * @param passphrase {string} - */ - replaceCosignerXpubWithSeed(externalIndex, mnemonic, passphrase) { - const index = externalIndex - 1; - const fingerprint = this._cosignersFingerprints[index]; - if (!MultisigHDWallet.isXpubValid(this._cosigners[index])) throw new Error('This cosigner doesnt contain valid xpub'); - if (!bip39.validateMnemonic(mnemonic)) throw new Error('Not a valid mnemonic phrase'); - if (fingerprint !== MultisigHDWallet.mnemonicToFingerprint(mnemonic, passphrase)) { - throw new Error('Fingerprint of new seed doesnt match'); - } - this._cosigners[index] = mnemonic.trim(); - this._cosignersPassphrases[index] = passphrase || undefined; - } - - /** - * Looks up cosigner with seed by index, and repalces it with xpub - * - * @param externalIndex {number} - */ - replaceCosignerSeedWithXpub(externalIndex) { - const index = externalIndex - 1; - const mnemonics = this._cosigners[index]; - if (!bip39.validateMnemonic(mnemonics)) throw new Error('This cosigner doesnt contain valid xpub mnemonic phrase'); - const passphrase = this._cosignersPassphrases[index]; - const path = this._cosignersCustomPaths[index] || this._derivationPath; - const xpub = this.convertXpubToMultisignatureXpub(MultisigHDWallet.seedToXpub(mnemonics, path, passphrase)); - this._cosigners[index] = xpub; - this._cosignersPassphrases[index] = undefined; - } - - deleteCosigner(fp) { - const foundIndex = this._cosignersFingerprints.indexOf(fp); - if (foundIndex === -1) throw new Error('Cant find cosigner by fingerprint'); - - this._cosignersFingerprints = this._cosignersFingerprints.filter((el, index) => { - return index !== foundIndex; - }); - - this._cosigners = this._cosigners.filter((el, index) => { - return index !== foundIndex; - }); - - this._cosignersCustomPaths = this._cosignersCustomPaths.filter((el, index) => { - return index !== foundIndex; - }); - - this._cosignersPassphrases = this._cosignersPassphrases.filter((el, index) => { - return index !== foundIndex; - }); - - /* const newCosigners = []; - for (let c = 0; c < this._cosignersFingerprints.length; c++) { - if (c !== index) newCosigners.push(this._cosignersFingerprints[c]); - } */ - - // this._cosignersFingerprints = newCosigners; - } - - getFormat() { - if (this.isNativeSegwit()) return this.constructor.FORMAT_P2WSH; - if (this.isWrappedSegwit()) return this.constructor.FORMAT_P2SH_P2WSH; - if (this.isLegacy()) return this.constructor.FORMAT_P2SH; - - throw new Error('This should never happen'); - } - - /** - * @param fp {string} Exactly 8 chars of hex - * @return {boolean} - */ - static isFpValid(fp) { - if (fp.length !== 8) return false; - return /^[0-9A-F]{8}$/i.test(fp); - } - - /** - * Returns TRUE only for _multisignature_ xpubs as per SLIP-0132 - * (capital Z, capital Y, or just xpub) - * @see https://github.com/satoshilabs/slips/blob/master/slip-0132.md - * - * @param xpub - * @return {boolean} - */ - static isXpubForMultisig(xpub) { - return ['xpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); - } - - isSegwit() { - return this.isNativeSegwit() || this.isWrappedSegwit(); - } -} diff --git a/class/wallets/multisig-hd-wallet.ts b/class/wallets/multisig-hd-wallet.ts new file mode 100644 index 00000000000..e85d5499b6a --- /dev/null +++ b/class/wallets/multisig-hd-wallet.ts @@ -0,0 +1,1292 @@ +import BIP32Factory, { BIP32Interface } from 'bip32'; +import * as bip39 from 'bip39'; +import * as bitcoin from 'bitcoinjs-lib'; +import { Psbt, Transaction } from 'bitcoinjs-lib'; +import b58 from 'bs58check'; +import { CoinSelectOutput, CoinSelectReturnInput, CoinSelectTarget } from 'coinselect'; +import { sha256 } from '@noble/hashes/sha256'; +import { ECPairFactory } from 'ecpair'; +import * as mn from 'electrum-mnemonic'; + +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import ecc from '../../blue_modules/noble_ecc'; +import { decodeUR } from '../../blue_modules/ur'; +import { AbstractHDElectrumWallet } from './abstract-hd-electrum-wallet'; +import { CreateTransactionResult, CreateTransactionTarget, CreateTransactionUtxo } from './types'; +import { + uint8ArrayToHex, + hexToUint8Array, + concatUint8Arrays, + uint8ArrayToString, + compareUint8Arrays, +} from '../../blue_modules/uint8array-extras'; + +const ECPair = ECPairFactory(ecc); +const bip32 = BIP32Factory(ecc); + +type SeedOpts = { + prefix: string; + passphrase?: string; +}; + +type TBip32Derivation = { + masterFingerprint: Uint8Array; + path: string; + pubkey: Uint8Array; +}[]; + +type TOutputData = + | { + bip32Derivation: TBip32Derivation; + redeemScript: Uint8Array; + } + | { + bip32Derivation: TBip32Derivation; + witnessScript: Uint8Array; + } + | { + bip32Derivation: TBip32Derivation; + redeemScript: Uint8Array; + witnessScript: Uint8Array; + }; + +const electrumSegwit = (passphrase?: string): SeedOpts => ({ + prefix: mn.PREFIXES.segwit, + ...(passphrase ? { passphrase } : {}), +}); + +const electrumStandart = (passphrase?: string): SeedOpts => ({ + prefix: mn.PREFIXES.standard, + ...(passphrase ? { passphrase } : {}), +}); + +const ELECTRUM_SEED_PREFIX = 'electrumseed:'; + +export class MultisigHDWallet extends AbstractHDElectrumWallet { + static readonly type = 'HDmultisig'; + static readonly typeReadable = 'Multisig Vault'; + // @ts-ignore: override + public readonly type = MultisigHDWallet.type; + // @ts-ignore: override + public readonly typeReadable = MultisigHDWallet.typeReadable; + + static FORMAT_P2WSH = 'p2wsh'; + static FORMAT_P2SH_P2WSH = 'p2sh-p2wsh'; + static FORMAT_P2SH_P2WSH_ALT = 'p2wsh-p2sh'; + static FORMAT_P2SH = 'p2sh'; + + static PATH_NATIVE_SEGWIT = "m/48'/0'/0'/2'"; + static PATH_WRAPPED_SEGWIT = "m/48'/0'/0'/1'"; + static PATH_LEGACY = "m/45'"; + + private _m: number = 0; // minimum required signatures so spend (m out of n) + private _cosigners: string[] = []; // array of xpubs or mnemonic seeds + private _cosignersFingerprints: string[] = []; // array of according fingerprints (if any provided) + private _cosignersCustomPaths: string[] = []; // array of according paths (if any provided) + private _cosignersPassphrases: (string | undefined)[] = []; // array of according passphrases (if any provided) + private _isNativeSegwit: boolean = false; + private _isWrappedSegwit: boolean = false; + private _isLegacy: boolean = false; + private _nodes: Record> = {}; // nodeIndex -> cosignerIndex -> BIP32Interface + private _cosignerXpubCache: Record = {}; // cosigner+path+passphrase -> xpub, since deriving xpub from seed is expensive + public _derivationPath: string = ''; + public gap_limit: number = 20; + + isLegacy() { + return this._isLegacy; + } + + isNativeSegwit() { + return this._isNativeSegwit; + } + + isWrappedSegwit() { + return this._isWrappedSegwit; + } + + setWrappedSegwit() { + this._isWrappedSegwit = true; + } + + setNativeSegwit() { + this._isNativeSegwit = true; + } + + setLegacy() { + this._isLegacy = true; + } + + setM(m: number) { + this._m = m; + } + + /** + * @returns {number} How many minumim signatures required to authorize a spend + */ + getM(): number { + return this._m; + } + + /** + * @returns {number} Total count of cosigners + */ + getN(): number { + return this._cosigners.length; + } + + setDerivationPath(path: string) { + this._derivationPath = path; + switch (this._derivationPath) { + case "m/48'/0'/0'/2'": + this._isNativeSegwit = true; + break; + case "m/48'/0'/0'/1'": + this._isWrappedSegwit = true; + break; + case "m/45'": + this._isLegacy = true; + break; + case "m/44'": + this._isLegacy = true; + break; + } + } + + getCustomDerivationPathForCosigner(index: number): string | false { + if (index === 0) throw new Error('cosigners indexation starts from 1'); + if (index > this.getN()) return false; + return this._cosignersCustomPaths[index - 1] || this.getDerivationPath()!; + } + + getCosigner(index: number) { + if (index === 0) throw new Error('cosigners indexation starts from 1'); + return this._cosigners[index - 1]; + } + + getFingerprint(index: number) { + if (index === 0) throw new Error('cosigners fingerprints indexation starts from 1'); + return this._cosignersFingerprints[index - 1]; + } + + getCosignerForFingerprint(fp: string) { + const index = this._cosignersFingerprints.indexOf(fp); + return this._cosigners[index]; + } + + getCosignerPassphrase(index: number) { + if (index === 0) throw new Error('cosigners indexation starts from 1'); + return this._cosignersPassphrases[index - 1]; + } + + static isXpubValid(key: string): boolean { + let xpub; + + try { + const tempWallet = new MultisigHDWallet(); + xpub = tempWallet._zpubToXpub(key); + bip32.fromBase58(xpub); + return true; + } catch (_) {} + + return false; + } + + static isXprvValid(xprv: string): boolean { + try { + xprv = MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv); + bip32.fromBase58(xprv); + return true; + } catch (_) { + return false; + } + } + + /** + * + * @param key {string} Either xpub or mnemonic phrase + * @param fingerprint {string} Fingerprint for cosigner that is added as xpub + * @param path {string} Custom path (if any) for cosigner that is added as mnemonics + * @param passphrase {string} BIP38 Passphrase (if any) + */ + addCosigner(key: string, fingerprint?: string, path?: string, passphrase?: string) { + if (MultisigHDWallet.isXpubString(key) && !fingerprint) { + throw new Error('fingerprint is required when adding cosigner as xpub (watch-only)'); + } + + if (path && !MultisigHDWallet.isPathValid(path)) { + throw new Error('path is not valid'); + } + + if (MultisigHDWallet.isXprvString(key)) { + // nop, but probably should validate xprv + } else if (MultisigHDWallet.isXpubString(key)) { + // nop, just validate + if (!MultisigHDWallet.isXpubValid(key)) throw new Error('Not a valid xpub: ' + key); + } else if (key.startsWith(ELECTRUM_SEED_PREFIX) && fingerprint && path) { + // its an electrum seed + const mnemonic = key.replace(ELECTRUM_SEED_PREFIX, ''); + try { + mn.mnemonicToSeedSync(mnemonic, electrumStandart(passphrase)); + this.setLegacy(); + } catch (_) { + try { + mn.mnemonicToSeedSync(mnemonic, electrumSegwit(passphrase)); + this.setNativeSegwit(); + } catch (__) { + throw new Error('Not a valid electrum seed'); + } + } + } else { + // mnemonics. lets derive fingerprint (if it wasnt provided) + if (!bip39.validateMnemonic(key)) throw new Error('Not a valid mnemonic phrase'); + fingerprint = fingerprint || MultisigHDWallet.mnemonicToFingerprint(key, passphrase); + } + + if (fingerprint && this._cosignersFingerprints.indexOf(fingerprint.toUpperCase()) !== -1 && fingerprint !== '00000000') { + // 00000000 is a special case, means we have no idea what the FP is but its okay + throw new Error('Duplicate fingerprint'); + } + + const index = this._cosigners.length; + this._cosigners[index] = key; + if (fingerprint) this._cosignersFingerprints[index] = fingerprint.toUpperCase(); + if (path) this._cosignersCustomPaths[index] = path; + if (passphrase) this._cosignersPassphrases[index] = passphrase; + } + + static convertMultisigXprvToRegularXprv(Zprv: string) { + let data = b58.decode(Zprv); + data = data.slice(4); + return b58.encode(concatUint8Arrays([hexToUint8Array('0488ade4'), data])); + } + + static convertXprvToXpub(xprv: string) { + const restored = bip32.fromBase58(MultisigHDWallet.convertMultisigXprvToRegularXprv(xprv)); + return restored.neutered().toBase58(); + } + + /** + * Stored cosigner can be EITHER xpub (or Zpub or smth), OR mnemonic phrase. This method converts it to xpub + * + * @param index {number} + * @returns {string} xpub + * @private + */ + protected _getXpubFromCosignerIndex(index: number) { + if (!this._cosigners || !this._cosigners[index]) { + throw new Error('Invalid cosigner index or cosigners not initialized'); + } + let cosigner: string = this._cosigners[index]; + const cacheKey = + cosigner + '|' + (this._cosignersCustomPaths[index] || this._derivationPath) + '|' + (this._cosignersPassphrases[index] ?? ''); + if (this._cosignerXpubCache[cacheKey]) return this._cosignerXpubCache[cacheKey]; // cache hit + if (MultisigHDWallet.isXprvString(cosigner)) cosigner = MultisigHDWallet.convertXprvToXpub(cosigner); + let xpub = cosigner; + if (!MultisigHDWallet.isXpubString(cosigner)) { + xpub = MultisigHDWallet.seedToXpub( + cosigner, + this._cosignersCustomPaths[index] || this._derivationPath, + this._cosignersPassphrases[index], + ); + } + return (this._cosignerXpubCache[cacheKey] = this._zpubToXpub(xpub)); + } + + _getExternalAddressByIndex(index: number) { + if (!this._m) throw new Error('m is not set'); + index = +index; + if (this.external_addresses_cache[index]) return this.external_addresses_cache[index]; // cache hit + + const address = this._getAddressFromNode(0, index); + this.external_addresses_cache[index] = address; + return address; + } + + _getAddressFromNode(nodeIndex: number, index: number) { + this._nodes = this._nodes || {}; + const pubkeys = []; + for (const [cosignerIndex] of this._cosigners.entries()) { + this._nodes[nodeIndex] = this._nodes[nodeIndex] || {}; + let _node; + + if (!this._nodes[nodeIndex][cosignerIndex]) { + const xpub = this._getXpubFromCosignerIndex(cosignerIndex); + const hdNode = bip32.fromBase58(xpub); + _node = hdNode.derive(nodeIndex); + this._nodes[nodeIndex][cosignerIndex] = _node; + } else { + _node = this._nodes[nodeIndex][cosignerIndex]; + } + + pubkeys.push(_node.derive(index).publicKey); + } + + if (this.isWrappedSegwit()) { + const { address } = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }), + }); + if (!address) { + throw new Error('Internal error: could not make p2sh address'); + } + + return address; + } else if (this.isNativeSegwit()) { + const { address } = bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }); + if (!address) { + throw new Error('Internal error: could not make p2wsh address'); + } + + return address; + } else if (this.isLegacy()) { + const { address } = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }); + if (!address) { + throw new Error('Internal error: could not make p2sh address'); + } + + return address; + } else { + throw new Error('Dont know how to make address'); + } + } + + _getInternalAddressByIndex(index: number) { + if (!this._m) throw new Error('m is not set'); + index = +index; + if (this.internal_addresses_cache[index]) return this.internal_addresses_cache[index]; // cache hit + + const address = this._getAddressFromNode(1, index); + this.internal_addresses_cache[index] = address; + return address; + } + + static seedToXpub(mnemonic: string, path: string, passphrase?: string): string { + let seed; + if (mnemonic.startsWith(ELECTRUM_SEED_PREFIX)) { + seed = MultisigHDWallet.convertElectrumMnemonicToSeed(mnemonic, passphrase); + } else { + seed = bip39.mnemonicToSeedSync(mnemonic, passphrase); + } + + const root = bip32.fromSeed(seed); + const child = root.derivePath(path).neutered(); + return child.toBase58(); + } + + /** + * Returns xpub with correct prefix accodting to this objects set derivation path, for example 'Zpub' (with + * capital Z) for bech32 multisig + * @see https://github.com/satoshilabs/slips/blob/master/slip-0132.md + * + * @param xpub {string} Any kind of xpub, including zpub etc since we are only swapping the prefix bytes + * @returns {string} + */ + convertXpubToMultisignatureXpub(xpub: string): string { + let data = b58.decode(xpub); + data = data.slice(4); + if (this.isNativeSegwit()) { + return b58.encode(concatUint8Arrays([hexToUint8Array('02aa7ed3'), data])); + } else if (this.isWrappedSegwit()) { + return b58.encode(concatUint8Arrays([hexToUint8Array('0295b43f'), data])); + } + + return xpub; + } + + static isXpubString(xpub: string): boolean { + return ['xpub', 'ypub', 'zpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); + } + + static isXprvString(xpub: string): boolean { + return ['xprv', 'yprv', 'zprv', 'Yprv', 'Zprv'].includes(xpub.substring(0, 4)); + } + + /** + * Converts fingerprint that is stored as a deciman number to hex string (all caps) + * + * @param xfp {number} For example 64392470 + * @returns {string} For example 168DD603 + */ + static ckccXfp2fingerprint(xfp: string | number): string { + let masterFingerprintHex = Number(xfp).toString(16); + while (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + + // poor man's little-endian conversion: + // ¯\_(ツ)_/¯ + return ( + masterFingerprintHex[6] + + masterFingerprintHex[7] + + masterFingerprintHex[4] + + masterFingerprintHex[5] + + masterFingerprintHex[2] + + masterFingerprintHex[3] + + masterFingerprintHex[0] + + masterFingerprintHex[1] + ).toUpperCase(); + } + + getXpub() { + return this.getSecret(true); + } + + getSecret(coordinationSetup = false) { + let ret = '# BlueWallet Multisig setup file\n'; + if (coordinationSetup) ret += '# this file contains only public keys and is safe to\n# distribute among cosigners\n'; + if (!coordinationSetup) ret += '# this file may contain private information\n'; + ret += '#\n'; + ret += 'Name: ' + this.getLabel() + '\n'; + ret += 'Policy: ' + this.getM() + ' of ' + this.getN() + '\n'; + + let hasCustomPaths = 0; + const customPaths: Record = {}; + for (let index = 0; index < this.getN(); index++) { + if (this._cosignersCustomPaths[index]) hasCustomPaths++; + if (this._cosignersCustomPaths[index]) customPaths[this._cosignersCustomPaths[index]] = 1; + } + + let printedGlobalDerivation = false; + const derivationPath = this.getDerivationPath(); + if (derivationPath) customPaths[derivationPath] = 1; + if (Object.keys(customPaths).length === 1) { + // we have exactly one path, for everyone. lets just print it + for (const path of Object.keys(customPaths)) { + ret += 'Derivation: ' + path + '\n'; + printedGlobalDerivation = true; + } + } + + if (hasCustomPaths !== this.getN() && !printedGlobalDerivation) { + printedGlobalDerivation = true; + ret += 'Derivation: ' + this.getDerivationPath() + '\n'; + } + + if (this.isNativeSegwit()) { + ret += 'Format: P2WSH\n'; + } else if (this.isWrappedSegwit()) { + ret += 'Format: P2SH-P2WSH\n'; + } else if (this.isLegacy()) { + ret += 'Format: P2SH\n'; + } else { + ret += 'Format: unknown\n'; + } + ret += '\n'; + + for (let index = 0; index < this.getN(); index++) { + if ( + this._cosignersCustomPaths[index] && + ((printedGlobalDerivation && this._cosignersCustomPaths[index] !== this.getDerivationPath()) || !printedGlobalDerivation) + ) { + ret += '# derivation: ' + this._cosignersCustomPaths[index] + '\n'; + // if we printed global derivation and this cosigned _has_ derivation and its different from global - we print it ; + // or we print it if cosigner _has_ some derivation set and we did not print global + } + if (MultisigHDWallet.isXpubString(this._cosigners[index])) { + ret += this._cosignersFingerprints[index] + ': ' + this._cosigners[index] + '\n'; + } else { + if (coordinationSetup) { + const xpub = this.convertXpubToMultisignatureXpub( + MultisigHDWallet.seedToXpub( + this._cosigners[index], + this._cosignersCustomPaths[index] || this._derivationPath, + this._cosignersPassphrases[index], + ), + ); + const fingerprint = MultisigHDWallet.mnemonicToFingerprint(this._cosigners[index], this._cosignersPassphrases[index]); + ret += fingerprint + ': ' + xpub + '\n'; + } else { + ret += 'seed: ' + this._cosigners[index]; + if (this._cosignersPassphrases[index]) ret += ' - ' + this._cosignersPassphrases[index]; + ret += '\n# warning! sensitive information, do not disclose ^^^ \n'; + } + } + + ret += '\n'; + } + + return ret; + } + + setSecret(secret: string) { + if (secret.toUpperCase().startsWith('UR:BYTES')) { + const decoded = decodeUR([secret]) as string; + const b = hexToUint8Array(decoded); + secret = uint8ArrayToString(b); + } + + // is it Coldcard json file? + let json; + try { + json = JSON.parse(secret); + } catch (_) {} + if (json && json.xfp && json.p2wsh_deriv && json.p2wsh) { + this.addCosigner(json.p2wsh, json.xfp); // technically we dont need deriv (json.p2wsh_deriv), since cosigner is already an xpub + return this; + } + + // is it electrum json? + if (json && json.wallet_type && json.wallet_type !== 'standard') { + const mofn = json.wallet_type.split('of'); + this.setM(parseInt(mofn[0].trim(), 10)); + const n = parseInt(mofn[1].trim(), 10); + for (let c = 1; c <= n; c++) { + const cosignerData = json['x' + c + '/'] || json['x' + c]; + if (cosignerData) { + const derivationPath = cosignerData.derivation ? cosignerData.derivation.replace(/h/g, "'") : undefined; + const fingerprint = + (cosignerData.ckcc_xfp + ? MultisigHDWallet.ckccXfp2fingerprint(cosignerData.ckcc_xfp) + : cosignerData.root_fingerprint?.toUpperCase()) || '00000000'; + if (cosignerData.seed) { + this.addCosigner(ELECTRUM_SEED_PREFIX + cosignerData.seed, fingerprint, derivationPath, cosignerData.passphrase); + } else if (cosignerData.xprv && MultisigHDWallet.isXprvValid(cosignerData.xprv)) { + this.addCosigner(cosignerData.xprv, fingerprint, derivationPath); + } else { + this.addCosigner(cosignerData.xpub, fingerprint, derivationPath); + } + } + + if (cosignerData?.xpub?.startsWith('Zpub')) this.setNativeSegwit(); + if (cosignerData?.xpub?.startsWith('Ypub')) this.setWrappedSegwit(); + if (cosignerData?.xpub?.startsWith('xpub')) this.setLegacy(); + } + } + + // coldcard & cobo txt format: + let customPathForCurrentCosigner: string | undefined; + for (const line of secret.split('\n')) { + const [key, value] = line.split(':'); + + switch (key) { + case 'Name': + this.setLabel(value.trim()); + break; + + case 'Policy': + this.setM(parseInt(value.trim().split('of')[0].trim(), 10)); + break; + + case 'Derivation': + this.setDerivationPath(value.trim()); + break; + + case 'Format': + switch (value.trim()) { + case MultisigHDWallet.FORMAT_P2WSH.toUpperCase(): + this.setNativeSegwit(); + break; + case MultisigHDWallet.FORMAT_P2SH_P2WSH.toUpperCase(): + case MultisigHDWallet.FORMAT_P2SH_P2WSH_ALT.toUpperCase(): + this.setWrappedSegwit(); + break; + case MultisigHDWallet.FORMAT_P2SH.toUpperCase(): + this.setLegacy(); + break; + } + break; + + default: + if (key && value && MultisigHDWallet.isXpubString(value.trim())) { + this.addCosigner(value.trim(), key, customPathForCurrentCosigner); + } else if (key.replace('#', '').trim() === 'derivation') { + customPathForCurrentCosigner = value.trim(); + } else if (key === 'seed') { + const [seed, passphrase] = value.split(' - '); + this.addCosigner(seed.trim(), undefined, customPathForCurrentCosigner, passphrase); + } + break; + } + } + + // is it wallet descriptor? + // @see https://github.com/bitcoin/bitcoin/blob/master/doc/descriptors.md + // @see https://github.com/Fonta1n3/FullyNoded/blob/master/Docs/Wallets/Wallet-Export-Spec.md + if (!json && secret.indexOf('sortedmulti(')) { + // provided secret was NOT json but plain wallet descriptor text. lets mock json + json = { descriptor: secret, label: 'Multisig vault' }; + } + if (secret.indexOf('sortedmulti(') !== -1 && json.descriptor) { + if (json.label) this.setLabel(json.label); + if (json.descriptor.includes('sh(wsh(')) { + this.setWrappedSegwit(); + } else if (json.descriptor.includes('wsh(')) { + this.setNativeSegwit(); + } else if (json.descriptor.includes('sh(')) { + this.setLegacy(); + } + + const s2 = json.descriptor.substr(json.descriptor.indexOf('sortedmulti(') + 12); + const s3 = s2.split(','); + const M = parseInt(s3[0], 10); + if (M) this.setM(M); + + for (let c = 1; c < s3.length; c++) { + const re = /\[([^\]]+)\](.*)/; + const m = s3[c].match(re); + if (m && m.length === 3) { + const hexFingerprint = m[1].split('/')[0]; + + let path = 'm/' + m[1].split('/').slice(1).join('/').replace(/[h]/g, "'"); + if (path === 'm/') { + // not considered valid by Bip32 lib + path = 'm/0'; + } + let xpub = m[2]; + if (xpub.indexOf('/') !== -1) { + xpub = xpub.substr(0, xpub.indexOf('/')); + } + if (xpub.indexOf(')') !== -1) { + xpub = xpub.substr(0, xpub.indexOf(')')); + } + + this.addCosigner(xpub, hexFingerprint.toUpperCase(), path); + } + } + + if (this.getN() === 0) { + // handling a case when smth went wrong and we didnt parse any cosigners, probably because + // string is a bit non-standard, deesnt have chars like '[' + for (let c = 1; c < s3.length; c++) { + const hexFingerprint = s3[c].split('/')[0]; + let indexOfXpub = s3[c].indexOf('xpub'); + if (indexOfXpub === -1) { + // just for any case + indexOfXpub = s3[c].indexOf('ypub'); + } + if (indexOfXpub === -1) { + // just for any case + indexOfXpub = s3[c].indexOf('zpub'); + } + if (indexOfXpub === -1) { + throw new Error('Could not parse cosigner in a descriptor'); + } + + const xpub = s3[c].substring(indexOfXpub).replaceAll(')', ''); + const path = 'm' + s3[c].substring(hexFingerprint.length, indexOfXpub); + + this.addCosigner(xpub, hexFingerprint.toUpperCase(), path); + } + } + } + + // is it caravan? + if (json && json.network === 'mainnet' && json.quorum) { + this.setM(+json.quorum.requiredSigners); + if (json.name) this.setLabel(json.name); + + switch (json.addressType.toLowerCase()) { + case MultisigHDWallet.FORMAT_P2SH: + this.setLegacy(); + break; + case MultisigHDWallet.FORMAT_P2SH_P2WSH: + case MultisigHDWallet.FORMAT_P2SH_P2WSH_ALT: + this.setWrappedSegwit(); + break; + case MultisigHDWallet.FORMAT_P2WSH: + default: + this.setNativeSegwit(); + break; + } + + for (const pk of json.extendedPublicKeys) { + const path = MultisigHDWallet.isPathValid(json.bip32Path) ? json.bip32Path : "m/1'"; + this.addCosigner(pk.xpub, pk.xfp ?? '00000000', path); + } + } + + if (!this.getLabel()) this.setLabel('Multisig vault'); + + return this; + } + + _getDerivationPathByAddressWithCustomPath(address: string, customPathPrefix: string | undefined) { + const path = customPathPrefix || this._derivationPath; + for (let c = 0; c < this.next_free_address_index + this.gap_limit; c++) { + if (this._getExternalAddressByIndex(c) === address) return path + '/0/' + c; + } + for (let c = 0; c < this.next_free_change_address_index + this.gap_limit; c++) { + if (this._getInternalAddressByIndex(c) === address) return path + '/1/' + c; + } + + return false; + } + + _getWifForAddress(address: string): string { + // @ts-ignore not applicable in multisig + return false; + } + + _getPubkeyByAddress(address: string): false | Buffer { + throw new Error('Not applicable in multisig'); + } + + _getDerivationPathByAddress(address: string): string { + throw new Error('Not applicable in multisig'); + } + + /** + * Builds bip32Derivation entries (and the corresponding pubkeys) for every cosigner for a given address + * of this wallet. Used both for inputs and for change outputs. + */ + _getBip32DerivationsByAddress(address: string) { + const bip32Derivation: TBip32Derivation = []; // array per each pubkey thats gona be used + const pubkeys = []; + for (const [cosignerIndex] of this._cosigners.entries()) { + const path = this._getDerivationPathByAddressWithCustomPath( + address, + this._cosignersCustomPaths[cosignerIndex] || this._derivationPath, + ); + // ^^ path resembles _custom path_, if provided by user during setup, otherwise default path for wallet type gona be used + const masterFingerprint = hexToUint8Array(this._cosignersFingerprints[cosignerIndex]); + + if (!path) { + throw new Error('Could not find derivation path for address ' + address); + } + + const xpub = this._getXpubFromCosignerIndex(cosignerIndex); + const hdNode0 = bip32.fromBase58(xpub); + const splt = path.split('/'); + const internal = +splt[splt.length - 2]; + const index = +splt[splt.length - 1]; + const _node0 = hdNode0.derive(internal); + const pubkey = _node0.derive(index).publicKey; + pubkeys.push(pubkey); + + bip32Derivation.push({ + masterFingerprint, + path, + pubkey, + }); + } + + return { bip32Derivation, pubkeys }; + } + + _addPsbtInput(psbt: Psbt, input: CoinSelectReturnInput, sequence: number, masterFingerprintBuffer?: Uint8Array) { + if (!input.address) { + throw new Error('Could not find address in input'); + } + const { bip32Derivation, pubkeys } = this._getBip32DerivationsByAddress(input.address); + + if (!input.txhex) { + throw new Error('Electrum server didnt provide txhex to properly create PSBT transaction'); + } + + if (this.isNativeSegwit()) { + const p2wsh = bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }); + if (!p2wsh.redeem || !p2wsh.output) { + throw new Error('Could not create p2wsh output'); + } + const witnessScript = p2wsh.redeem.output; + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + bip32Derivation, + witnessUtxo: { + script: p2wsh.output, + value: BigInt(input.value), + }, + witnessScript, + // hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate + // some hw wallets attack vector + nonWitnessUtxo: hexToUint8Array(input.txhex), + }); + } else if (this.isWrappedSegwit()) { + const p2shP2wsh = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }), + }); + if (!p2shP2wsh?.redeem?.redeem?.output || !p2shP2wsh?.redeem?.output || !p2shP2wsh.output) { + throw new Error('Could not create p2sh-p2wsh output'); + } + + const witnessScript = p2shP2wsh.redeem.redeem.output; + const redeemScript = p2shP2wsh.redeem.output; + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + bip32Derivation, + witnessUtxo: { + script: p2shP2wsh.output, + value: BigInt(input.value), + }, + witnessScript, + redeemScript, + // hw wallets now require passing the whole previous tx as Buffer, as if it was non-segwit input, to mitigate + // some hw wallets attack vector + nonWitnessUtxo: hexToUint8Array(input.txhex), + }); + } else if (this.isLegacy()) { + const p2sh = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }); + if (!p2sh?.redeem?.output) { + throw new Error('Could not create p2sh output'); + } + const redeemScript = p2sh.redeem.output; + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + bip32Derivation, + redeemScript, + nonWitnessUtxo: hexToUint8Array(input.txhex), + }); + } else { + throw new Error('Dont know how to add input'); + } + + return psbt; + } + + _getOutputDataForChange(address: string): TOutputData { + const { bip32Derivation, pubkeys } = this._getBip32DerivationsByAddress(address); + + if (this.isLegacy()) { + const p2sh = bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }); + if (!p2sh.output) { + throw new Error('Could not create redeemScript'); + } + return { + bip32Derivation, + redeemScript: p2sh.output, + }; + } + + if (this.isWrappedSegwit()) { + const p2shP2wsh = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }), + }); + const witnessScript = p2shP2wsh?.redeem?.redeem?.output; + const redeemScript = p2shP2wsh?.redeem?.output; + if (!witnessScript || !redeemScript) { + throw new Error('Could not create redeemScript or witnessScript'); + } + return { + bip32Derivation, + witnessScript, + redeemScript, + }; + } + + if (this.isNativeSegwit()) { + // not needed by coldcard, apparently..? + const p2wsh = bitcoin.payments.p2wsh({ + redeem: bitcoin.payments.p2ms({ m: this._m, pubkeys: MultisigHDWallet.sortBuffers(pubkeys) }), + }); + const witnessScript = p2wsh?.redeem?.output; + if (!witnessScript) { + throw new Error('Could not create witnessScript'); + } + return { + bip32Derivation, + witnessScript, + }; + } + + throw new Error('dont know how to add change output'); + } + + howManySignaturesCanWeMake() { + let howManyPrivKeysWeGot = 0; + for (const cosigner of this._cosigners) { + if (!MultisigHDWallet.isXpubString(cosigner) && !MultisigHDWallet.isXprvString(cosigner)) howManyPrivKeysWeGot++; + } + + return howManyPrivKeysWeGot; + } + + coinselect( + utxos: CreateTransactionUtxo[], + targets: CreateTransactionTarget[], + feeRate: number, + ): { inputs: CoinSelectReturnInput[]; outputs: CoinSelectOutput[]; fee: number } { + const _utxos = JSON.parse(JSON.stringify(utxos)) as CreateTransactionUtxo[]; + + // overriding script length for proper vbytes calculation + for (const u of _utxos) { + if (u.script?.length) { + continue; + } + + if (this.isNativeSegwit()) { + u.script = { + length: Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4), + }; + } else if (this.isWrappedSegwit()) { + u.script = { + length: 35 + Math.ceil((8 + this.getM() * 74 + this.getN() * 34) / 4), + }; + } else { + u.script = { + length: 2 + this.getM() * 74 + this.getN() * 34, + }; + } + } + + return super.coinselect(_utxos, targets, feeRate); + } + + /** + * @inheritDoc + */ + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { + if (targets.length === 0) throw new Error('No destination provided'); + if (this.howManySignaturesCanWeMake() === 0) skipSigning = true; + + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + sequence = sequence || AbstractHDElectrumWallet.defaultRBFSequence; + + let psbt = new bitcoin.Psbt(); + + let c = 0; + inputs.forEach(input => { + c++; + psbt = this._addPsbtInput(psbt, input, sequence); + }); + + outputs.forEach(output => { + // if output has no address - this is change output + let change = false; + let address: string | undefined = output.address; + if (!address) { + change = true; + output.address = changeAddress; + address = changeAddress; + } + + let outputData: Parameters[0] = { + address, + value: BigInt(output.value), + }; + + if (change) { + outputData = { + ...outputData, + ...this._getOutputDataForChange(address), + }; + } + + psbt.addOutput(outputData); + }); + + if (!skipSigning) { + // deriving a seed is an expensive pbkdf2 operation, so we prepare each signing cosigner's hd root + // only once and reuse it for every input + const hdRoots: BIP32Interface[] = []; + for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { + if (MultisigHDWallet.isXpubString(cosigner)) continue; + // ok this is a mnemonic, lets try to sign + if (hdRoots.length >= this.getM()) { + // dont sign more than we need, otherwise there will be "Too many signatures" error + continue; + } + const passphrase = this._cosignersPassphrases[cosignerIndex]; + const seed = cosigner.startsWith(ELECTRUM_SEED_PREFIX) + ? MultisigHDWallet.convertElectrumMnemonicToSeed(cosigner, passphrase) + : bip39.mnemonicToSeedSync(cosigner, passphrase); + hdRoots.push(bip32.fromSeed(seed)); + } + + for (let cc = 0; cc < c; cc++) { + for (const hdRoot of hdRoots) { + psbt.signInputHD(cc, hdRoot); + } + } + } + + let tx; + if (!skipSigning && this.howManySignaturesCanWeMake() >= this.getM()) { + tx = psbt.finalizeAllInputs().extractTransaction(); + } + return { tx, inputs, outputs, fee, psbt }; + } + + static convertElectrumMnemonicToSeed(cosigner: string, passphrase?: string) { + let seed; + try { + seed = mn.mnemonicToSeedSync(cosigner.replace(ELECTRUM_SEED_PREFIX, ''), electrumSegwit(passphrase)); + } catch (_) { + try { + seed = mn.mnemonicToSeedSync(cosigner.replace(ELECTRUM_SEED_PREFIX, ''), electrumStandart(passphrase)); + } catch (__) { + throw new Error('Not a valid electrum mnemonic'); + } + } + return seed; + } + + /** + * @see https://github.com/bitcoin/bips/blob/master/bip-0067.mediawiki + */ + static sortBuffers(bufArr: Uint8Array[]): Uint8Array[] { + return bufArr.sort(compareUint8Arrays); + } + + prepareForSerialization() { + // deleting structures that cant be serialized + // @ts-ignore I dont want to make it optional + delete this._nodes; + this._cosignerXpubCache = {}; // no need to persist the cache + } + + static isPathValid(path: string): boolean { + const root = bip32.fromSeed(new Uint8Array(32)); + try { + root.derivePath(path); + return true; + } catch (_) {} + return false; + } + + allowSend() { + return true; + } + + allowSignVerifyMessage() { + return false; + } + + async fetchUtxo() { + await super.fetchUtxo(); + // now we need to fetch txhash for each input as required by PSBT + const utxos = this.getUtxo(true); + const txhexes = await BlueElectrum.multiGetTransactionByTxid( + utxos.map(x => x.txid), + false, + ); + + for (const u of utxos) { + if (txhexes[u.txid]) u.txhex = txhexes[u.txid]; + } + + this._utxo = utxos; + } + + getID() { + const string2hash = [...this._cosigners].sort().join(',') + ';' + [...this._cosignersFingerprints].sort().join(','); + return uint8ArrayToHex(sha256(string2hash)); + } + + calculateFeeFromPsbt(psbt: Psbt) { + let goesIn = 0; + const cacheUtxoAmounts: { [key: string]: number } = {}; + for (const inp of psbt.data.inputs) { + if (inp.witnessUtxo && inp.witnessUtxo.value) { + // segwit input + goesIn += Number(inp.witnessUtxo.value); + } else if (inp.nonWitnessUtxo) { + // non-segwit input + // lets parse this transaction and cache how much each input was worth + const inputTx = bitcoin.Transaction.fromBuffer(inp.nonWitnessUtxo); + let index = 0; + for (const out of inputTx.outs) { + cacheUtxoAmounts[inputTx.getId() + ':' + index] = Number(out.value); + index++; + } + } + } + + if (goesIn === 0) { + // means we failed to get amounts that go in previously, so lets use utxo amounts cache we've build + // from non-segwit inputs + for (const inp of psbt.txInputs) { + const cacheKey = uint8ArrayToHex(new Uint8Array(inp.hash).reverse()) + ':' + inp.index; + if (cacheUtxoAmounts[cacheKey]) goesIn += cacheUtxoAmounts[cacheKey]; + } + } + + let goesOut = 0; + for (const output of psbt.txOutputs) { + goesOut += Number(output.value); + } + + return goesIn - goesOut; + } + + calculateHowManySignaturesWeHaveFromPsbt(psbt: Psbt) { + let sigsHave = 0; + for (const inp of psbt.data.inputs) { + sigsHave = Math.max(sigsHave, inp.partialSig?.length || 0); + if (inp.finalScriptSig || inp.finalScriptWitness) sigsHave = this.getM(); // hacky, but it means we have enough + // He who knows that enough is enough will always have enough. Lao Tzu + } + + return sigsHave; + } + + /** + * Tries to signs passed psbt object (by reference). If there are enough signatures - tries to finalize psbt + * and returns Transaction (ready to extract hex) + */ + cosignPsbt(psbt: Psbt): { tx: Transaction | false } { + // deriving a seed is an expensive pbkdf2 operation, so we prepare each signing cosigner's hd root + // only once and reuse it for every input + const hdRoots: { cosignerIndex: number; hdRoot: BIP32Interface }[] = []; + for (const [cosignerIndex, cosigner] of this._cosigners.entries()) { + if (MultisigHDWallet.isXpubString(cosigner)) continue; + + let hdRoot; + if (MultisigHDWallet.isXprvString(cosigner)) { + const xprv = MultisigHDWallet.convertMultisigXprvToRegularXprv(cosigner); + hdRoot = bip32.fromBase58(xprv); + } else { + const passphrase = this._cosignersPassphrases[cosignerIndex]; + const seed = cosigner.startsWith(ELECTRUM_SEED_PREFIX) + ? MultisigHDWallet.convertElectrumMnemonicToSeed(cosigner, passphrase) + : bip39.mnemonicToSeedSync(cosigner, passphrase); + hdRoot = bip32.fromSeed(seed); + } + hdRoots.push({ cosignerIndex, hdRoot }); + } + + for (let cc = 0; cc < psbt.inputCount; cc++) { + for (const { cosignerIndex, hdRoot } of hdRoots) { + try { + psbt.signInputHD(cc, hdRoot); + } catch (_) {} // protects agains duplicate cosignings + + if (!psbt.inputHasHDKey(cc, hdRoot)) { + // failed signing as HD. probably bitcoinjs-lib could not match provided hdRoot's + // fingerprint (or path?) to the ones in psbt, which is the case of stupid Electrum desktop which can + // put bullshit paths and fingerprints in created psbt. + // lets try to find correct priv key and sign manually. + for (const derivation of psbt.data.inputs[cc].bip32Derivation || []) { + // okay, here we assume that fingerprint is irrelevant, but ending of the path is somewhat correct and + // correctly points to `/internal/index`, so we extract pubkey from our stored mnemonics+path and + // match it to the one provided in PSBT's input, and if we have a match - we are in luck! we can sign + // with this private key. + const splt = derivation.path.split('/'); + const internal = +splt[splt.length - 2]; + const index = +splt[splt.length - 1]; + + const path = + hdRoot.depth === 0 + ? this.getCustomDerivationPathForCosigner(cosignerIndex + 1) + `/${internal ? 1 : 0}/${index}` + : `${internal ? 1 : 0}/${index}`; + // ^^^ we assume that counterparty has Zpub for specified derivation path + // if hdRoot.depth !== 0 than this hdnode was recovered from xprv and it already has been set to root path + const child = hdRoot.derivePath(path); + if (child.privateKey && psbt.inputHasPubkey(cc, child.publicKey)) { + const keyPair = ECPair.fromPrivateKey(child.privateKey); + try { + psbt.signInput(cc, keyPair); + } catch (_) {} + } + } + } + } + } + + if (this.calculateHowManySignaturesWeHaveFromPsbt(psbt) >= this.getM()) { + const tx = psbt.finalizeAllInputs().extractTransaction(); + return { tx }; + } + + return { tx: false }; + } + + /** + * Looks up xpub cosigner by index, and repalces it with seed + passphrase + */ + replaceCosignerXpubWithSeed(externalIndex: number, mnemonic: string, passphrase?: string) { + const index = externalIndex - 1; + const fingerprint = this._cosignersFingerprints[index]; + if (!MultisigHDWallet.isXpubValid(this._cosigners[index])) throw new Error('This cosigner doesnt contain valid xpub'); + if (!bip39.validateMnemonic(mnemonic)) throw new Error('Not a valid mnemonic phrase'); + if (fingerprint !== MultisigHDWallet.mnemonicToFingerprint(mnemonic, passphrase)) { + throw new Error('Fingerprint of new seed doesnt match'); + } + this._cosigners[index] = mnemonic.trim(); + this._cosignersPassphrases[index] = passphrase || undefined; + } + + /** + * Looks up cosigner with seed by index, and repalces it with xpub + */ + replaceCosignerSeedWithXpub(externalIndex: number) { + const index = externalIndex - 1; + const mnemonics = this._cosigners[index]; + if (!bip39.validateMnemonic(mnemonics)) throw new Error('This cosigner doesnt contain valid xpub mnemonic phrase'); + const passphrase = this._cosignersPassphrases[index]; + const path = this._cosignersCustomPaths[index] || this._derivationPath; + const xpub = this.convertXpubToMultisignatureXpub(MultisigHDWallet.seedToXpub(mnemonics, path, passphrase)); + this._cosigners[index] = xpub; + this._cosignersPassphrases[index] = undefined; + } + + deleteCosigner(fp: string) { + const foundIndex = this._cosignersFingerprints.indexOf(fp); + if (foundIndex === -1) throw new Error('Cant find cosigner by fingerprint'); + + this._cosignersFingerprints = this._cosignersFingerprints.filter((el, index) => { + return index !== foundIndex; + }); + + this._cosigners = this._cosigners.filter((el, index) => { + return index !== foundIndex; + }); + + this._cosignersCustomPaths = this._cosignersCustomPaths.filter((el, index) => { + return index !== foundIndex; + }); + + this._cosignersPassphrases = this._cosignersPassphrases.filter((el, index) => { + return index !== foundIndex; + }); + } + + getFormat() { + if (this.isNativeSegwit()) return MultisigHDWallet.FORMAT_P2WSH; + if (this.isWrappedSegwit()) return MultisigHDWallet.FORMAT_P2SH_P2WSH; + if (this.isLegacy()) return MultisigHDWallet.FORMAT_P2SH; + + throw new Error('This should never happen'); + } + + /** + * @param fp {string} Exactly 8 chars of hex + * @return {boolean} + */ + static isFpValid(fp: string) { + if (fp.length !== 8) return false; + return /^[0-9A-F]{8}$/i.test(fp); + } + + /** + * Returns TRUE only for _multisignature_ xpubs as per SLIP-0132 + * (capital Z, capital Y, or just xpub) + * @see https://github.com/satoshilabs/slips/blob/master/slip-0132.md + * + * @param xpub + * @return {boolean} + */ + static isXpubForMultisig(xpub: string): boolean { + return ['xpub', 'Ypub', 'Zpub'].includes(xpub.substring(0, 4)); + } + + isSegwit() { + return this.isNativeSegwit() || this.isWrappedSegwit(); + } +} diff --git a/class/wallets/segwit-bech32-wallet.js b/class/wallets/segwit-bech32-wallet.js deleted file mode 100644 index e184dcdc60b..00000000000 --- a/class/wallets/segwit-bech32-wallet.js +++ /dev/null @@ -1,140 +0,0 @@ -import { LegacyWallet } from './legacy-wallet'; -import { ECPairFactory } from 'ecpair'; -import ecc from '../../blue_modules/noble_ecc'; -const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); - -export class SegwitBech32Wallet extends LegacyWallet { - static type = 'segwitBech32'; - static typeReadable = 'P2 WPKH'; - static segwitType = 'p2wpkh'; - - getAddress() { - if (this._address) return this._address; - let address; - try { - const keyPair = ECPair.fromWIF(this.secret); - if (!keyPair.compressed) { - console.warn('only compressed public keys are good for segwit'); - return false; - } - address = bitcoin.payments.p2wpkh({ - pubkey: keyPair.publicKey, - }).address; - } catch (err) { - return false; - } - this._address = address; - - return this._address; - } - - static witnessToAddress(witness) { - try { - const pubKey = Buffer.from(witness, 'hex'); - return bitcoin.payments.p2wpkh({ - pubkey: pubKey, - network: bitcoin.networks.bitcoin, - }).address; - } catch (_) { - return false; - } - } - - /** - * Converts script pub key to bech32 address if it can. Returns FALSE if it cant. - * - * @param scriptPubKey - * @returns {boolean|string} Either bech32 address or false - */ - static scriptPubKeyToAddress(scriptPubKey) { - try { - const scriptPubKey2 = Buffer.from(scriptPubKey, 'hex'); - return bitcoin.payments.p2wpkh({ - output: scriptPubKey2, - network: bitcoin.networks.bitcoin, - }).address; - } catch (_) { - return false; - } - } - - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { - if (targets.length === 0) throw new Error('No destination provided'); - // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation - for (const u of utxos) { - u.script = { length: 27 }; - } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); - sequence = sequence || 0xffffffff; // disable RBF by default - const psbt = new bitcoin.Psbt(); - let c = 0; - const values = {}; - let keyPair; - - inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } - values[c] = input.value; - c++; - - const pubkey = keyPair.publicKey; - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); - - psbt.addInput({ - hash: input.txid, - index: input.vout, - sequence, - witnessUtxo: { - script: p2wpkh.output, - value: input.value, - }, - }); - }); - - outputs.forEach(output => { - // if output has no address - this is change output - if (!output.address) { - output.address = changeAddress; - } - - const outputData = { - address: output.address, - value: output.value, - }; - - psbt.addOutput(outputData); - }); - - if (!skipSigning) { - // skiping signing related stuff - for (let cc = 0; cc < c; cc++) { - psbt.signInput(cc, keyPair); - } - } - - let tx; - if (!skipSigning) { - tx = psbt.finalizeAllInputs().extractTransaction(); - } - return { tx, inputs, outputs, fee, psbt }; - } - - allowSend() { - return true; - } - - allowSendMax() { - return true; - } - - isSegwit() { - return true; - } - - allowSignVerifyMessage() { - return true; - } -} diff --git a/class/wallets/segwit-bech32-wallet.ts b/class/wallets/segwit-bech32-wallet.ts new file mode 100644 index 00000000000..60c5d75bab5 --- /dev/null +++ b/class/wallets/segwit-bech32-wallet.ts @@ -0,0 +1,150 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import { CoinSelectTarget } from 'coinselect'; +import { ECPairFactory } from 'ecpair'; + +import ecc from '../../blue_modules/noble_ecc'; +import { LegacyWallet } from './legacy-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types'; +import { hexToUint8Array } from '../../blue_modules/uint8array-extras'; + +const ECPair = ECPairFactory(ecc); + +export class SegwitBech32Wallet extends LegacyWallet { + static readonly type = 'segwitBech32'; + static readonly typeReadable = 'SegWit (P2WPKH)'; + // @ts-ignore: override + public readonly type = SegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = SegwitBech32Wallet.typeReadable; + public readonly segwitType = 'p2wpkh'; + + getAddress(): string | false { + if (this._address) return this._address; + let address; + try { + const keyPair = ECPair.fromWIF(this.secret); + if (!keyPair.compressed) { + console.warn('only compressed public keys are good for segwit'); + return false; + } + address = bitcoin.payments.p2wpkh({ + pubkey: keyPair.publicKey, + }).address; + } catch (err) { + return false; + } + this._address = address ?? false; + + return this._address; + } + + static witnessToAddress(witness: string): string | false { + try { + const pubkey = hexToUint8Array(witness); + return ( + bitcoin.payments.p2wpkh({ + pubkey, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); + } catch (_) { + return false; + } + } + + /** + * Converts script pub key to bech32 address if it can. Returns FALSE if it cant. + * + * @param scriptPubKey + * @returns {boolean|string} Either bech32 address or false + */ + static scriptPubKeyToAddress(scriptPubKey: string): string | false { + try { + const scriptPubKey2 = hexToUint8Array(scriptPubKey); + return ( + bitcoin.payments.p2wpkh({ + output: scriptPubKey2, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); + } catch (_) { + return false; + } + } + + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { + if (targets.length === 0) throw new Error('No destination provided'); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + sequence = sequence || 0xffffffff; // disable RBF by default + const psbt = new bitcoin.Psbt(); + let c = 0; + const keyPair = ECPair.fromWIF(this.secret); + + inputs.forEach(input => { + c++; + + const pubkey = keyPair.publicKey; + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + if (!p2wpkh.output) { + throw new Error('Internal error: no p2wpkh.output during createTransaction()'); + } + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + witnessUtxo: { + script: p2wpkh.output, + value: BigInt(input.value), + }, + }); + }); + + outputs.forEach(output => { + // if output has no address - this is change output + if (!output.address) { + output.address = changeAddress; + } + + const outputData = { + address: output.address, + value: BigInt(output.value), + }; + + psbt.addOutput(outputData); + }); + + if (!skipSigning) { + // skiping signing related stuff + for (let cc = 0; cc < c; cc++) { + psbt.signInput(cc, keyPair); + } + } + + let tx; + if (!skipSigning) { + tx = psbt.finalizeAllInputs().extractTransaction(); + } + return { tx, inputs, outputs, fee, psbt }; + } + + allowSend() { + return true; + } + + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return true; + } +} diff --git a/class/wallets/segwit-p2sh-wallet.js b/class/wallets/segwit-p2sh-wallet.js deleted file mode 100644 index d0e28a558f6..00000000000 --- a/class/wallets/segwit-p2sh-wallet.js +++ /dev/null @@ -1,160 +0,0 @@ -import { LegacyWallet } from './legacy-wallet'; -import { ECPairFactory } from 'ecpair'; -import ecc from '../../blue_modules/noble_ecc'; -const ECPair = ECPairFactory(ecc); -const bitcoin = require('bitcoinjs-lib'); - -/** - * Creates Segwit P2SH Bitcoin address - * @param pubkey - * @param network - * @returns {String} - */ -function pubkeyToP2shSegwitAddress(pubkey, network) { - network = network || bitcoin.networks.bitcoin; - const { address } = bitcoin.payments.p2sh({ - redeem: bitcoin.payments.p2wpkh({ pubkey, network }), - network, - }); - return address; -} - -export class SegwitP2SHWallet extends LegacyWallet { - static type = 'segwitP2SH'; - static typeReadable = 'SegWit (P2SH)'; - static segwitType = 'p2sh(p2wpkh)'; - - static witnessToAddress(witness) { - try { - const pubKey = Buffer.from(witness, 'hex'); - return pubkeyToP2shSegwitAddress(pubKey); - } catch (_) { - return false; - } - } - - /** - * Converts script pub key to p2sh address if it can. Returns FALSE if it cant. - * - * @param scriptPubKey - * @returns {boolean|string} Either p2sh address or false - */ - static scriptPubKeyToAddress(scriptPubKey) { - try { - const scriptPubKey2 = Buffer.from(scriptPubKey, 'hex'); - return bitcoin.payments.p2sh({ - output: scriptPubKey2, - network: bitcoin.networks.bitcoin, - }).address; - } catch (_) { - return false; - } - } - - getAddress() { - if (this._address) return this._address; - let address; - try { - const keyPair = ECPair.fromWIF(this.secret); - const pubKey = keyPair.publicKey; - if (!keyPair.compressed) { - console.warn('only compressed public keys are good for segwit'); - return false; - } - address = pubkeyToP2shSegwitAddress(pubKey); - } catch (err) { - return false; - } - this._address = address; - - return this._address; - } - - /** - * - * @param utxos {Array.<{vout: Number, value: Number, txId: String, address: String, txhex: String, }>} List of spendable utxos - * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) - * @param feeRate {Number} satoshi per byte - * @param changeAddress {String} Excessive coins will go back to that address - * @param sequence {Number} Used in RBF - * @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case - * @param masterFingerprint {number} Decimal number of wallet's master fingerprint - * @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}} - */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence, skipSigning = false, masterFingerprint) { - if (targets.length === 0) throw new Error('No destination provided'); - // compensating for coinselect inability to deal with segwit inputs, and overriding script length for proper vbytes calculation - for (const u of utxos) { - u.script = { length: 50 }; - } - const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate, changeAddress); - sequence = sequence || 0xffffffff; // disable RBF by default - const psbt = new bitcoin.Psbt(); - let c = 0; - const values = {}; - let keyPair; - - inputs.forEach(input => { - if (!skipSigning) { - // skiping signing related stuff - keyPair = ECPair.fromWIF(this.secret); // secret is WIF - } - values[c] = input.value; - c++; - - const pubkey = keyPair.publicKey; - const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); - const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); - - psbt.addInput({ - hash: input.txid, - index: input.vout, - sequence, - witnessUtxo: { - script: p2sh.output, - value: input.value, - }, - redeemScript: p2wpkh.output, - }); - }); - - outputs.forEach(output => { - // if output has no address - this is change output - if (!output.address) { - output.address = changeAddress; - } - - const outputData = { - address: output.address, - value: output.value, - }; - - psbt.addOutput(outputData); - }); - - if (!skipSigning) { - // skiping signing related stuff - for (let cc = 0; cc < c; cc++) { - psbt.signInput(cc, keyPair); - } - } - - let tx; - if (!skipSigning) { - tx = psbt.finalizeAllInputs().extractTransaction(); - } - return { tx, inputs, outputs, fee, psbt }; - } - - allowSendMax() { - return true; - } - - isSegwit() { - return true; - } - - allowSignVerifyMessage() { - return true; - } -} diff --git a/class/wallets/segwit-p2sh-wallet.ts b/class/wallets/segwit-p2sh-wallet.ts new file mode 100644 index 00000000000..6d1c611e345 --- /dev/null +++ b/class/wallets/segwit-p2sh-wallet.ts @@ -0,0 +1,166 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import { CoinSelectTarget } from 'coinselect'; +import { ECPairFactory } from 'ecpair'; + +import ecc from '../../blue_modules/noble_ecc'; +import { LegacyWallet } from './legacy-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types'; +import { hexToUint8Array } from '../../blue_modules/uint8array-extras'; + +const ECPair = ECPairFactory(ecc); + +/** + * Creates Segwit P2SH Bitcoin address + * @param pubkey + * @param network + * @returns {String} + */ +function pubkeyToP2shSegwitAddress(pubkey: Uint8Array): string | false { + const { address } = bitcoin.payments.p2sh({ + redeem: bitcoin.payments.p2wpkh({ pubkey }), + }); + return address ?? false; +} + +export class SegwitP2SHWallet extends LegacyWallet { + static readonly type = 'segwitP2SH'; + static readonly typeReadable = 'SegWit (P2SH)'; + // @ts-ignore: override + public readonly type = SegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SegwitP2SHWallet.typeReadable; + public readonly segwitType = 'p2sh(p2wpkh)'; + + static witnessToAddress(witness: string): string | false { + try { + const pubKey = hexToUint8Array(witness); + return pubkeyToP2shSegwitAddress(pubKey); + } catch (_) { + return false; + } + } + + /** + * Converts script pub key to p2sh address if it can. Returns FALSE if it cant. + * + * @param scriptPubKey + * @returns {boolean|string} Either p2sh address or false + */ + static scriptPubKeyToAddress(scriptPubKey: string): string | false { + try { + const scriptPubKey2 = hexToUint8Array(scriptPubKey); + return ( + bitcoin.payments.p2sh({ + output: scriptPubKey2, + network: bitcoin.networks.bitcoin, + }).address ?? false + ); + } catch (_) { + return false; + } + } + + getAddress(): string | false { + if (this._address) return this._address; + let address; + try { + const keyPair = ECPair.fromWIF(this.secret); + const pubKey = keyPair.publicKey; + if (!keyPair.compressed) { + console.warn('only compressed public keys are good for segwit'); + return false; + } + address = pubkeyToP2shSegwitAddress(pubKey); + } catch (err) { + return false; + } + this._address = address; + + return this._address; + } + + /** + * + * @param utxos {Array.<{vout: Number, value: Number, txid: String, address: String, txhex: String, }>} List of spendable utxos + * @param targets {Array.<{value: Number, address: String}>} Where coins are going. If theres only 1 target and that target has no value - this will send MAX to that address (respecting fee rate) + * @param feeRate {Number} satoshi per byte + * @param changeAddress {String} Excessive coins will go back to that address + * @param sequence {Number} Used in RBF + * @param skipSigning {boolean} Whether we should skip signing, use returned `psbt` in that case + * @param masterFingerprint {number} Decimal number of wallet's master fingerprint + * @returns {{outputs: Array, tx: Transaction, inputs: Array, fee: Number, psbt: Psbt}} + */ + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { + if (targets.length === 0) throw new Error('No destination provided'); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + sequence = sequence || 0xffffffff; // disable RBF by default + const psbt = new bitcoin.Psbt(); + let c = 0; + const keyPair = ECPair.fromWIF(this.secret); + + inputs.forEach(input => { + c++; + + const pubkey = keyPair.publicKey; + const p2wpkh = bitcoin.payments.p2wpkh({ pubkey }); + const p2sh = bitcoin.payments.p2sh({ redeem: p2wpkh }); + if (!p2sh.output) { + throw new Error('Internal error: no p2sh.output during createTransaction()'); + } + + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + witnessUtxo: { + script: p2sh.output, + value: BigInt(input.value), + }, + redeemScript: p2wpkh.output, + }); + }); + + outputs.forEach(output => { + // if output has no address - this is change output + if (!output.address) { + output.address = changeAddress; + } + + const outputData = { + address: output.address, + value: BigInt(output.value), + }; + + psbt.addOutput(outputData); + }); + + if (!skipSigning) { + // skiping signing related stuff + for (let cc = 0; cc < c; cc++) { + psbt.signInput(cc, keyPair); + } + } + + let tx; + if (!skipSigning) { + tx = psbt.finalizeAllInputs().extractTransaction(); + } + return { tx, inputs, outputs, fee, psbt }; + } + + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return true; + } +} diff --git a/class/wallets/slip39-wallets.js b/class/wallets/slip39-wallets.js deleted file mode 100644 index a8672ec1a0a..00000000000 --- a/class/wallets/slip39-wallets.js +++ /dev/null @@ -1,102 +0,0 @@ -import slip39 from 'slip39'; -import { WORD_LIST } from 'slip39/dist/slip39_helper'; -import createHash from 'create-hash'; - -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; - -// collection of SLIP39 functions -const SLIP39Mixin = { - _getSeed() { - const master = slip39.recoverSecret(this.secret, this.passphrase); - return Buffer.from(master); - }, - - validateMnemonic() { - if (!this.secret.every(m => slip39.validateMnemonic(m))) return false; - - try { - slip39.recoverSecret(this.secret); - } catch (e) { - return false; - } - return true; - }, - - setSecret(newSecret) { - // Try to match words to the default slip39 wordlist and complete partial words - const lookupMap = WORD_LIST.reduce((map, word) => { - const prefix3 = word.substr(0, 3); - const prefix4 = word.substr(0, 4); - - map.set(prefix3, !map.has(prefix3) ? word : false); - map.set(prefix4, !map.has(prefix4) ? word : false); - - return map; - }, new Map()); - - this.secret = newSecret - .trim() - .split('\n') - .filter(s => s) - .map(s => { - let secret = s - .trim() - .toLowerCase() - .replace(/[^a-zA-Z0-9]/g, ' ') - .replace(/\s+/g, ' '); - - secret = secret - .split(' ') - .map(word => lookupMap.get(word) || word) - .join(' '); - - return secret; - }); - return this; - }, - - getID() { - const string2hash = this.secret.sort().join(',') + (this.getPassphrase() || ''); - return createHash('sha256').update(string2hash).digest().toString('hex'); - }, -}; - -export class SLIP39LegacyP2PKHWallet extends HDLegacyP2PKHWallet { - static type = 'SLIP39legacyP2PKH'; - static typeReadable = 'SLIP39 Legacy (P2PKH)'; - - allowBIP47() { - return false; - } - - _getSeed = SLIP39Mixin._getSeed; - validateMnemonic = SLIP39Mixin.validateMnemonic; - setSecret = SLIP39Mixin.setSecret; - getID = SLIP39Mixin.getID; -} - -export class SLIP39SegwitP2SHWallet extends HDSegwitP2SHWallet { - static type = 'SLIP39segwitP2SH'; - static typeReadable = 'SLIP39 SegWit (P2SH)'; - - _getSeed = SLIP39Mixin._getSeed; - validateMnemonic = SLIP39Mixin.validateMnemonic; - setSecret = SLIP39Mixin.setSecret; - getID = SLIP39Mixin.getID; -} - -export class SLIP39SegwitBech32Wallet extends HDSegwitBech32Wallet { - static type = 'SLIP39segwitBech32'; - static typeReadable = 'SLIP39 SegWit (Bech32)'; - - allowBIP47() { - return false; - } - - _getSeed = SLIP39Mixin._getSeed; - validateMnemonic = SLIP39Mixin.validateMnemonic; - setSecret = SLIP39Mixin.setSecret; - getID = SLIP39Mixin.getID; -} diff --git a/class/wallets/slip39-wallets.ts b/class/wallets/slip39-wallets.ts new file mode 100644 index 00000000000..857f948d779 --- /dev/null +++ b/class/wallets/slip39-wallets.ts @@ -0,0 +1,126 @@ +import { sha256 } from '@noble/hashes/sha256'; +import slip39 from 'slip39'; +import { WORD_LIST } from 'slip39/src/slip39_helper'; + +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; +import { uint8ArrayToHex } from '../../blue_modules/uint8array-extras'; + +type TWalletThis = Omit & { + secret: string[]; +}; + +// collection of SLIP39 functions +const SLIP39Mixin = { + _getSeed(): Uint8Array { + const self = this as unknown as TWalletThis; + const master = slip39.recoverSecret(self.secret, self.passphrase); + return Uint8Array.from(master); + }, + + validateMnemonic() { + const self = this as unknown as TWalletThis; + if (!self.secret.every(m => slip39.validateMnemonic(m))) return false; + + try { + slip39.recoverSecret(self.secret); + } catch (e) { + return false; + } + return true; + }, + + setSecret(newSecret: string) { + const self = this as unknown as TWalletThis; + // Try to match words to the default slip39 wordlist and complete partial words + const lookupMap = WORD_LIST.reduce((map, word) => { + const prefix3 = word.substr(0, 3); + const prefix4 = word.substr(0, 4); + + map.set(prefix3, !map.has(prefix3) ? word : false); + map.set(prefix4, !map.has(prefix4) ? word : false); + + return map; + }, new Map()); + + self.secret = newSecret + .trim() + .split('\n') + .filter(s => s) + .map(s => { + let secret = s + .trim() + .toLowerCase() + .replace(/[^a-zA-Z0-9]/g, ' ') + .replace(/\s+/g, ' '); + + secret = secret + .split(' ') + .map(word => lookupMap.get(word) || word) + .join(' '); + + return secret; + }); + return self; + }, + + getID() { + const self = this as unknown as TWalletThis; + const string2hash = self.secret.sort().join(',') + (self.getPassphrase() || ''); + return uint8ArrayToHex(sha256(string2hash)); + }, +}; + +export class SLIP39LegacyP2PKHWallet extends HDLegacyP2PKHWallet { + static readonly type = 'SLIP39legacyP2PKH'; + static readonly typeReadable = 'SLIP39 Legacy (P2PKH)'; + // @ts-ignore: override + public readonly type = SLIP39LegacyP2PKHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39LegacyP2PKHWallet.typeReadable; + + allowBIP47() { + return false; + } + + _getSeed = SLIP39Mixin._getSeed; + validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch + setSecret = SLIP39Mixin.setSecret; + getID = SLIP39Mixin.getID; +} + +export class SLIP39SegwitP2SHWallet extends HDSegwitP2SHWallet { + static readonly type = 'SLIP39segwitP2SH'; + static readonly typeReadable = 'SLIP39 SegWit (P2SH)'; + // @ts-ignore: override + public readonly type = SLIP39SegwitP2SHWallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39SegwitP2SHWallet.typeReadable; + + _getSeed = SLIP39Mixin._getSeed; + validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch + setSecret = SLIP39Mixin.setSecret; + getID = SLIP39Mixin.getID; +} + +export class SLIP39SegwitBech32Wallet extends HDSegwitBech32Wallet { + static readonly type = 'SLIP39segwitBech32'; + static readonly typeReadable = 'SLIP39 SegWit (Bech32)'; + // @ts-ignore: override + public readonly type = SLIP39SegwitBech32Wallet.type; + // @ts-ignore: override + public readonly typeReadable = SLIP39SegwitBech32Wallet.typeReadable; + + allowBIP47() { + return false; + } + + _getSeed = SLIP39Mixin._getSeed; + validateMnemonic = SLIP39Mixin.validateMnemonic; + // @ts-ignore: this type mismatch + setSecret = SLIP39Mixin.setSecret; + getID = SLIP39Mixin.getID; +} diff --git a/class/wallets/taproot-wallet.js b/class/wallets/taproot-wallet.js deleted file mode 100644 index 67e47d4989c..00000000000 --- a/class/wallets/taproot-wallet.js +++ /dev/null @@ -1,23 +0,0 @@ -import { SegwitBech32Wallet } from './segwit-bech32-wallet'; -const bitcoin = require('bitcoinjs-lib'); - -export class TaprootWallet extends SegwitBech32Wallet { - static type = 'taproot'; - static typeReadable = 'P2 TR'; - static segwitType = 'p2wpkh'; - - /** - * Converts script pub key to a Taproot address if it can. Returns FALSE if it cant. - * - * @param scriptPubKey - * @returns {boolean|string} Either bech32 address or false - */ - static scriptPubKeyToAddress(scriptPubKey) { - try { - const publicKey = Buffer.from(scriptPubKey, 'hex'); - return bitcoin.address.fromOutputScript(publicKey, bitcoin.networks.bitcoin); - } catch (_) { - return false; - } - } -} diff --git a/class/wallets/taproot-wallet.ts b/class/wallets/taproot-wallet.ts new file mode 100644 index 00000000000..4d97f5301af --- /dev/null +++ b/class/wallets/taproot-wallet.ts @@ -0,0 +1,135 @@ +import * as bitcoin from 'bitcoinjs-lib'; +import { ECPairFactory } from 'ecpair'; + +import ecc from '../../blue_modules/noble_ecc'; + +import { SegwitBech32Wallet } from './segwit-bech32-wallet'; +import { CreateTransactionResult, CreateTransactionUtxo } from './types.ts'; +import { CoinSelectTarget } from 'coinselect'; +import { hexToUint8Array } from '../../blue_modules/uint8array-extras'; +const ECPair = ECPairFactory(ecc); + +export class TaprootWallet extends SegwitBech32Wallet { + static readonly type = 'taproot'; + static readonly typeReadable = 'Taproot (P2TR)'; + // @ts-ignore: override + public readonly type = TaprootWallet.type; + // @ts-ignore: override + public readonly typeReadable = TaprootWallet.typeReadable; + public readonly segwitType = 'p2wpkh'; + + /** + * Converts script pub key to a Taproot address if it can. Returns FALSE if it cant. + * + * @param scriptPubKey + * @returns {boolean|string} Either bech32 address or false + */ + static scriptPubKeyToAddress(scriptPubKey: string): string | false { + try { + const publicKey = hexToUint8Array(scriptPubKey); + return bitcoin.address.fromOutputScript(publicKey, bitcoin.networks.bitcoin); + } catch (_) { + return false; + } + } + + allowSend() { + return true; + } + + isSegwit() { + return true; + } + + allowSignVerifyMessage() { + return true; + } + + getAddress(): string | false { + if (this._address) return this._address; + let address; + try { + const keyPair = ECPair.fromWIF(this.secret); + if (!keyPair.compressed) { + console.warn('only compressed public keys are good for segwit'); + return false; + } + const xOnlyPubkey = keyPair.publicKey.subarray(1, 33); + address = bitcoin.payments.p2tr({ + internalPubkey: xOnlyPubkey, + }).address; + } catch (err: any) { + console.log(err.message); + return false; + } + this._address = address ?? false; + + return this._address; + } + + createTransaction( + utxos: CreateTransactionUtxo[], + targets: CoinSelectTarget[], + feeRate: number, + changeAddress: string, + sequence: number, + skipSigning = false, + masterFingerprint: number, + ): CreateTransactionResult { + if (targets.length === 0) throw new Error('No destination provided'); + const { inputs, outputs, fee } = this.coinselect(utxos, targets, feeRate); + sequence = sequence || 0xffffffff; // default if not passed + + // Derive keyPair & x-only pubkey + const keyPair = ECPair.fromWIF(this.secret); + const pubkey = keyPair.publicKey; // compressed: 0x02/03 || X + const xOnlyPub = pubkey.subarray(1, 33); // strip prefix + + // Precompute the P2TR payment (to rebuild scriptPubKey) + const p2tr = bitcoin.payments.p2tr({ + internalPubkey: xOnlyPub, + }); + if (!p2tr.output) throw new Error('Could not build p2tr.output'); + + const psbt = new bitcoin.Psbt(); + + // Add Taproot inputs + inputs.forEach((input, idx) => { + psbt.addInput({ + hash: input.txid, + index: input.vout, + sequence, + witnessUtxo: { + script: p2tr.output!, + value: BigInt(input.value), + }, + // tell PSBT it’s a key-path Taproot spend + tapInternalKey: xOnlyPub, + }); + }); + + // Add outputs + outputs.forEach(output => { + // if output has no address - this is change output + if (!output.address) output.address = changeAddress; + psbt.addOutput({ + address: output.address!, + value: BigInt(output.value), + }); + }); + + let tx; + if (!skipSigning) { + // Sign each input as a Taproot key-path spend + inputs.forEach((_, idx) => { + psbt.signTaprootInput(idx, keyPair.tweak(bitcoin.crypto.taggedHash('TapTweak', xOnlyPub))); + }); + + // Finalize all inputs (will auto-detect Taproot) + psbt.finalizeAllInputs(); + tx = psbt.extractTransaction(); + } + + return { tx, inputs, outputs, fee, psbt }; + } +} diff --git a/class/wallets/types.ts b/class/wallets/types.ts index e4d9b10a423..5de0c766fe8 100644 --- a/class/wallets/types.ts +++ b/class/wallets/types.ts @@ -1,34 +1,53 @@ -import bitcoin from 'bitcoinjs-lib'; -import { CoinSelectOutput, CoinSelectReturnInput } from 'coinselect'; +import * as bitcoin from 'bitcoinjs-lib'; +import { CoinSelectOutput, CoinSelectReturnInput, CoinSelectUtxo } from 'coinselect'; + +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { HDAezeedWallet } from './hd-aezeed-wallet'; +import { HDLegacyBreadwalletWallet } from './hd-legacy-breadwallet-wallet'; +import { HDLegacyElectrumSeedP2PKHWallet } from './hd-legacy-electrum-seed-p2pkh-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitElectrumSeedP2WPKHWallet } from './hd-segwit-electrum-seed-p2wpkh-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './legacy-wallet'; +import { LightningCustodianWallet } from './lightning-custodian-wallet'; +import { MultisigHDWallet } from './multisig-hd-wallet'; +import { SegwitBech32Wallet } from './segwit-bech32-wallet'; +import { SegwitP2SHWallet } from './segwit-p2sh-wallet'; +import { SLIP39LegacyP2PKHWallet, SLIP39SegwitBech32Wallet, SLIP39SegwitP2SHWallet } from './slip39-wallets'; +import { WatchOnlyWallet } from './watch-only-wallet'; +import { TaprootWallet } from './taproot-wallet.ts'; +import { HDTaprootWallet } from './hd-taproot-wallet.ts'; +import { LightningArkWallet } from './lightning-ark-wallet.ts'; export type Utxo = { // Returned by BlueElectrum height: number; address: string; - txId: string; + txid: string; vout: number; value: number; // Others txhex?: string; - txid?: string; // TODO: same as txId, do we really need it? confirmations?: number; - amount?: number; // TODO: same as value, do we really need it? wif?: string | false; }; /** - * basically the same as coinselect.d.ts/CoinselectUtxo - * and should be unified as soon as bullshit with txid/txId is sorted + * same as coinselect.d.ts/CoinSelectUtxo */ -export type CreateTransactionUtxo = { - txId: string; - txid: string; // TODO: same as txId, do we really need it? - txhex: string; - vout: number; - value: number; +export interface CreateTransactionUtxo extends CoinSelectUtxo {} + +/** + * if address is missing and `script.hex` is set - this is a custom script (like OP_RETURN) + */ +export type CreateTransactionTarget = { + address?: string; + value?: number; script?: { - length: number; + length?: number; // either length or hex should be present + hex?: string; }; }; @@ -63,6 +82,42 @@ export type TransactionOutput = { }; }; +export interface DecodedInvoice { + destination: string; + payment_hash: string; + num_satoshis: number; + timestamp: number; + expiry: number; + description: string; + description_hash: string; + fallback_addr: string; + cltv_expiry: string; + route_hints: any[]; + [key: string]: any; +} + +export type LightningTransaction = { + memo?: string; + type?: 'user_invoice' | 'payment_request' | 'bitcoind_tx' | 'paid_invoice'; + payment_hash?: string | { data: string }; + category?: 'receive'; + timestamp: number; // seconds, not milliseconds + expire_time?: number; + ispaid?: boolean; + // Terminal non-success state (failed/refunded/expired swap). Distinct from + // `ispaid:false`, which on its own only means "not settled yet" and is also + // true for in-flight rows. Consumers that gate on pending vs. dead state + // (e.g. the wallet-card pending pill) must treat `failed` rows as terminal. + failed?: boolean; + walletID?: string; + value?: number; + amt?: number; + fee?: number; + payment_preimage?: string; + payment_request?: string; + description?: string; +}; + export type Transaction = { txid: string; hash: string; @@ -73,10 +128,48 @@ export type Transaction = { locktime: number; inputs: TransactionInput[]; outputs: TransactionOutput[]; - blockhash: string; + // Confirmation-only fields: absent on mempool (unconfirmed) responses. + blockhash?: string; confirmations?: number; - time: number; - blocktime: number; - received?: number; + time?: number; + blocktime?: number; + timestamp: number; // seconds, not milliseconds value?: number; + + /** + * if known, who is on the other end of the transaction (BIP47 payment code) + */ + counterparty?: string; +}; + +/** + * in some cases we add additional data to each tx object so the code that works with that transaction can find the + * wallet that owns it etc + */ +export type ExtendedTransaction = Transaction & { + walletID: string; + walletPreferredBalanceUnit: BitcoinUnit; }; + +export type TWallet = + | HDAezeedWallet + | HDLegacyBreadwalletWallet + | HDLegacyElectrumSeedP2PKHWallet + | HDLegacyP2PKHWallet + | HDSegwitBech32Wallet + | HDSegwitElectrumSeedP2WPKHWallet + | HDSegwitP2SHWallet + | HDTaprootWallet + | LegacyWallet + | LightningArkWallet + | LightningCustodianWallet + | MultisigHDWallet + | SLIP39LegacyP2PKHWallet + | SLIP39SegwitBech32Wallet + | SLIP39SegwitP2SHWallet + | SegwitBech32Wallet + | SegwitP2SHWallet + | TaprootWallet + | WatchOnlyWallet; + +export type THDWalletForWatchOnly = HDSegwitBech32Wallet | HDSegwitP2SHWallet | HDLegacyP2PKHWallet | HDTaprootWallet; diff --git a/class/wallets/watch-only-wallet.js b/class/wallets/watch-only-wallet.js deleted file mode 100644 index 60f8955ec9d..00000000000 --- a/class/wallets/watch-only-wallet.js +++ /dev/null @@ -1,311 +0,0 @@ -import { LegacyWallet } from './legacy-wallet'; -import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; -import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; -import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; -import BIP32Factory from 'bip32'; -import ecc from '../../blue_modules/noble_ecc'; - -const bitcoin = require('bitcoinjs-lib'); -const bip32 = BIP32Factory(ecc); - -export class WatchOnlyWallet extends LegacyWallet { - static type = 'watchOnly'; - static typeReadable = 'Watch-only'; - - constructor() { - super(); - this.use_with_hardware_wallet = false; - this.masterFingerprint = false; - } - - /** - * @inheritDoc - */ - getLastTxFetch() { - if (this._hdWalletInstance) return this._hdWalletInstance.getLastTxFetch(); - return super.getLastTxFetch(); - } - - timeToRefreshTransaction() { - if (this._hdWalletInstance) return this._hdWalletInstance.timeToRefreshTransaction(); - return super.timeToRefreshTransaction(); - } - - timeToRefreshBalance() { - if (this._hdWalletInstance) return this._hdWalletInstance.timeToRefreshBalance(); - return super.timeToRefreshBalance(); - } - - allowSend() { - return this.useWithHardwareWalletEnabled() && this.isHd() && this._hdWalletInstance.allowSend(); - } - - allowSignVerifyMessage() { - return false; - } - - getAddress() { - if (this.isAddressValid(this.secret)) return this.secret; // handling case when there is an XPUB there - if (this._hdWalletInstance) throw new Error('Should not be used in watch-only HD wallets'); - throw new Error('Not initialized'); - } - - valid() { - if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) return this.isXpubValid(); - - try { - bitcoin.address.toOutputScript(this.getAddress()); - return true; - } catch (_) { - return false; - } - } - - /** - * this method creates appropriate HD wallet class, depending on whether we have xpub, ypub or zpub - * as a property of `this`, and in case such property exists - it recreates it and copies data from old one. - * this is needed after serialization/save/load/deserialization procedure. - * - * @return {WatchOnlyWallet} this - */ - init() { - let hdWalletInstance; - if (this.secret.startsWith('xpub')) hdWalletInstance = new HDLegacyP2PKHWallet(); - else if (this.secret.startsWith('ypub')) hdWalletInstance = new HDSegwitP2SHWallet(); - else if (this.secret.startsWith('zpub')) hdWalletInstance = new HDSegwitBech32Wallet(); - else return this; - hdWalletInstance._xpub = this.secret; - - // if derivation path recovered from JSON file it should be moved to hdWalletInstance - if (this._derivationPath) { - hdWalletInstance._derivationPath = this._derivationPath; - } - - if (this._hdWalletInstance) { - // now, porting all properties from old object to new one - for (const k of Object.keys(this._hdWalletInstance)) { - hdWalletInstance[k] = this._hdWalletInstance[k]; - } - - // deleting properties that cant survive serialization/deserialization: - delete hdWalletInstance._node1; - delete hdWalletInstance._node0; - } - this._hdWalletInstance = hdWalletInstance; - - return this; - } - - prepareForSerialization() { - if (this._hdWalletInstance) { - delete this._hdWalletInstance._node0; - delete this._hdWalletInstance._node1; - delete this._hdWalletInstance._bip47_instance; - } - } - - getBalance() { - if (this._hdWalletInstance) return this._hdWalletInstance.getBalance(); - return super.getBalance(); - } - - getTransactions() { - if (this._hdWalletInstance) return this._hdWalletInstance.getTransactions(); - return super.getTransactions(); - } - - async fetchBalance() { - if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) { - if (!this._hdWalletInstance) this.init(); - return this._hdWalletInstance.fetchBalance(); - } else { - // return LegacyWallet.prototype.fetchBalance.call(this); - return super.fetchBalance(); - } - } - - async fetchTransactions() { - if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) { - if (!this._hdWalletInstance) this.init(); - return this._hdWalletInstance.fetchTransactions(); - } else { - // return LegacyWallet.prototype.fetchBalance.call(this); - return super.fetchTransactions(); - } - } - - async getAddressAsync() { - if (this.isAddressValid(this.secret)) return new Promise(resolve => resolve(this.secret)); - if (this._hdWalletInstance) return this._hdWalletInstance.getAddressAsync(); - throw new Error('Not initialized'); - } - - _getExternalAddressByIndex(index) { - if (this._hdWalletInstance) return this._hdWalletInstance._getExternalAddressByIndex(index); - throw new Error('Not initialized'); - } - - _getInternalAddressByIndex(index) { - if (this._hdWalletInstance) return this._hdWalletInstance._getInternalAddressByIndex(index); - throw new Error('Not initialized'); - } - - getNextFreeAddressIndex() { - if (this._hdWalletInstance) return this._hdWalletInstance.next_free_address_index; - throw new Error('Not initialized'); - } - - getNextFreeChangeAddressIndex() { - if (this._hdWalletInstance) return this._hdWalletInstance.next_free_change_address_index; - throw new Error('Not initialized'); - } - - async getChangeAddressAsync() { - if (this._hdWalletInstance) return this._hdWalletInstance.getChangeAddressAsync(); - throw new Error('Not initialized'); - } - - async fetchUtxo() { - if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo(); - throw new Error('Not initialized'); - } - - getUtxo(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args); - throw new Error('Not initialized'); - } - - combinePsbt(base64one, base64two) { - if (this._hdWalletInstance) return this._hdWalletInstance.combinePsbt(base64one, base64two); - throw new Error('Not initialized'); - } - - broadcastTx(hex) { - if (this._hdWalletInstance) return this._hdWalletInstance.broadcastTx(hex); - throw new Error('Not initialized'); - } - - /** - * signature of this method is the same ad BIP84 createTransaction, BUT this method should be used to create - * unsinged PSBT to be used with HW wallet (or other external signer) - * @see HDSegwitBech32Wallet.createTransaction - */ - createTransaction(utxos, targets, feeRate, changeAddress, sequence) { - if (this._hdWalletInstance && this.isHd()) { - return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, this.getMasterFingerprint()); - } else { - throw new Error('Not a HD watch-only wallet, cant create PSBT (or just not initialized)'); - } - } - - getMasterFingerprint() { - return this.masterFingerprint; - } - - getMasterFingerprintHex() { - if (!this.masterFingerprint) return '00000000'; - let masterFingerprintHex = Number(this.masterFingerprint).toString(16); - if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte - // poor man's little-endian conversion: - // ¯\_(ツ)_/¯ - return ( - masterFingerprintHex[6] + - masterFingerprintHex[7] + - masterFingerprintHex[4] + - masterFingerprintHex[5] + - masterFingerprintHex[2] + - masterFingerprintHex[3] + - masterFingerprintHex[0] + - masterFingerprintHex[1] - ); - } - - isHd() { - return this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub'); - } - - weOwnAddress(address) { - if (this.isHd()) { - if (this._hdWalletInstance) return this._hdWalletInstance.weOwnAddress(address); - throw new Error('Not initialized'); - } - - if (address && address.startsWith('BC1')) address = address.toLowerCase(); - - return this.getAddress() === address; - } - - allowHodlHodlTrading() { - return this.isHd(); - } - - allowMasterFingerprint() { - return this.getSecret().startsWith('zpub'); - } - - useWithHardwareWalletEnabled() { - return !!this.use_with_hardware_wallet; - } - - setUseWithHardwareWalletEnabled(enabled) { - this.use_with_hardware_wallet = !!enabled; - } - - /** - * @inheritDoc - */ - getAllExternalAddresses() { - if (this._hdWalletInstance) return this._hdWalletInstance.getAllExternalAddresses(); - return super.getAllExternalAddresses(); - } - - isXpubValid() { - let xpub; - - try { - if (this.secret.startsWith('zpub')) { - xpub = this._zpubToXpub(this.secret); - } else if (this.secret.startsWith('ypub')) { - xpub = this.constructor._ypubToXpub(this.secret); - } else { - xpub = this.secret; - } - - const hdNode = bip32.fromBase58(xpub); - hdNode.derive(0); - return true; - } catch (_) {} - - return false; - } - - addressIsChange(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.addressIsChange(...args); - return super.addressIsChange(...args); - } - - getUTXOMetadata(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.getUTXOMetadata(...args); - return super.getUTXOMetadata(...args); - } - - setUTXOMetadata(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.setUTXOMetadata(...args); - return super.setUTXOMetadata(...args); - } - - getDerivationPath(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.getDerivationPath(...args); - throw new Error("Not a HD watch-only wallet, can't use derivation path"); - } - - setDerivationPath(...args) { - if (this._hdWalletInstance) return this._hdWalletInstance.setDerivationPath(...args); - throw new Error("Not a HD watch-only wallet, can't use derivation path"); - } - - isSegwit() { - if (this._hdWalletInstance) return this._hdWalletInstance.isSegwit(); - return super.isSegwit(); - } -} diff --git a/class/wallets/watch-only-wallet.ts b/class/wallets/watch-only-wallet.ts new file mode 100644 index 00000000000..603d75a188a --- /dev/null +++ b/class/wallets/watch-only-wallet.ts @@ -0,0 +1,351 @@ +import BIP32Factory from 'bip32'; +import * as bitcoin from 'bitcoinjs-lib'; +import ecc from '../../blue_modules/noble_ecc'; +import { AbstractWallet } from './abstract-wallet'; +import { HDLegacyP2PKHWallet } from './hd-legacy-p2pkh-wallet'; +import { HDSegwitBech32Wallet } from './hd-segwit-bech32-wallet'; +import { HDSegwitP2SHWallet } from './hd-segwit-p2sh-wallet'; +import { LegacyWallet } from './legacy-wallet'; +import { THDWalletForWatchOnly } from './types'; +import { HDTaprootWallet } from './hd-taproot-wallet'; + +const bip32 = BIP32Factory(ecc); + +export class WatchOnlyWallet extends LegacyWallet { + static readonly type = 'watchOnly'; + static readonly typeReadable = 'Watch-only'; + // @ts-ignore: override + public readonly type = WatchOnlyWallet.type; + // @ts-ignore: override + public readonly typeReadable = WatchOnlyWallet.typeReadable; + public isWatchOnlyWarningVisible = true; + + public _hdWalletInstance?: THDWalletForWatchOnly; + use_with_hardware_wallet = false; + masterFingerprint: number = 0; + + /** + * @inheritDoc + */ + getLastTxFetch() { + if (this._hdWalletInstance) return this._hdWalletInstance.getLastTxFetch(); + return super.getLastTxFetch(); + } + + timeToRefreshTransaction() { + if (this._hdWalletInstance) return this._hdWalletInstance.timeToRefreshTransaction(); + return super.timeToRefreshTransaction(); + } + + timeToRefreshBalance() { + if (this._hdWalletInstance) return this._hdWalletInstance.timeToRefreshBalance(); + return super.timeToRefreshBalance(); + } + + allowSend() { + return this.useWithHardwareWalletEnabled() && this.isHd() && this._hdWalletInstance!.allowSend(); + } + + allowRBF() { + return this._hdWalletInstance?.allowRBF() ?? false; + } + + allowSignVerifyMessage() { + return false; + } + + getAddress() { + if (this.isAddressValid(this.secret)) return this.secret; // handling case when there is an XPUB there + if (this._hdWalletInstance) throw new Error('Should not be used in watch-only HD wallets'); + throw new Error('Not initialized'); + } + + valid() { + if (this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub')) return this.isXpubValid(); + + try { + bitcoin.address.toOutputScript(this.getAddress()); + return true; + } catch (_) { + return false; + } + } + + /** + * this method creates appropriate HD wallet class, depending on whether we have xpub, ypub or zpub + * as a property of `this`, and in case such property exists - it recreates it and copies data from old one. + * this is needed after serialization/save/load/deserialization procedure. + */ + init() { + let hdWalletInstance: THDWalletForWatchOnly; + + // Check script type first (most reliable - parsed from descriptor) + if (this.segwitType === 'p2tr') { + hdWalletInstance = new HDTaprootWallet(); + } else if (this.segwitType === 'p2wpkh') { + hdWalletInstance = new HDSegwitBech32Wallet(); + } else if (this.segwitType === 'p2sh(p2wpkh)') { + hdWalletInstance = new HDSegwitP2SHWallet(); + } else if (this.segwitType === 'p2pkh') { + hdWalletInstance = new HDLegacyP2PKHWallet(); + } + // Fallback to path-based detection (for bare [fingerprint/path]xpub without descriptor wrapper) + else if (this._derivationPath?.startsWith("m/86'")) { + // if path is explicit taproot path - its definately BIP86 + hdWalletInstance = new HDTaprootWallet(); + } else if (this._derivationPath?.startsWith("m/84'")) { + hdWalletInstance = new HDSegwitBech32Wallet(); + } else if (this._derivationPath?.startsWith("m/49'")) { + hdWalletInstance = new HDSegwitP2SHWallet(); + } + // Final fallback to xpub prefix (legacy behavior for bare xpub/ypub/zpub) + else if (this.secret.startsWith('xpub')) { + hdWalletInstance = new HDLegacyP2PKHWallet(); + } else if (this.secret.startsWith('ypub')) hdWalletInstance = new HDSegwitP2SHWallet(); + else if (this.secret.startsWith('zpub')) hdWalletInstance = new HDSegwitBech32Wallet(); + else return this; + hdWalletInstance._xpub = this.secret; + + // if derivation path recovered from JSON file it should be moved to hdWalletInstance + if (this._derivationPath) { + hdWalletInstance._derivationPath = this._derivationPath; + } + + if (this._hdWalletInstance) { + // now, porting all properties from old object to new one + for (const k of Object.keys(this._hdWalletInstance)) { + // @ts-ignore: JS magic here + hdWalletInstance[k] = this._hdWalletInstance[k]; + } + + // deleting properties that cant survive serialization/deserialization: + delete hdWalletInstance._node1; + delete hdWalletInstance._node0; + } + this._hdWalletInstance = hdWalletInstance; + + return this; + } + + prepareForSerialization() { + if (this._hdWalletInstance) { + delete this._hdWalletInstance._node0; + delete this._hdWalletInstance._node1; + delete this._hdWalletInstance._bip47_instance; + } + } + + getBalance() { + if (this._hdWalletInstance) return this._hdWalletInstance.getBalance(); + return super.getBalance(); + } + + getTransactions() { + if (this._hdWalletInstance) return this._hdWalletInstance.getTransactions(); + return super.getTransactions(); + } + + async fetchBalance() { + if (this.isHd()) { + if (!this._hdWalletInstance) this.init(); + if (!this._hdWalletInstance) throw new Error('Internal error: _hdWalletInstance is not initialized'); + return this._hdWalletInstance.fetchBalance(); + } else { + return super.fetchBalance(); + } + } + + async fetchTransactions() { + if (this.isHd()) { + if (!this._hdWalletInstance) this.init(); + if (!this._hdWalletInstance) throw new Error('Internal error: _hdWalletInstance is not initialized'); + return this._hdWalletInstance.fetchTransactions(); + } else { + return super.fetchTransactions(); + } + } + + async getAddressAsync(): Promise { + if (this.isAddressValid(this.secret)) return Promise.resolve(this.secret); + if (this._hdWalletInstance) return this._hdWalletInstance.getAddressAsync(); + throw new Error('Not initialized'); + } + + _getExternalAddressByIndex(index: number) { + if (this._hdWalletInstance) return this._hdWalletInstance._getExternalAddressByIndex(index); + throw new Error('Not initialized'); + } + + _getInternalAddressByIndex(index: number) { + if (this._hdWalletInstance) return this._hdWalletInstance._getInternalAddressByIndex(index); + throw new Error('Not initialized'); + } + + getNextFreeAddressIndex() { + if (this._hdWalletInstance) return this._hdWalletInstance.next_free_address_index; + throw new Error('Not initialized'); + } + + getNextFreeChangeAddressIndex() { + if (this._hdWalletInstance) return this._hdWalletInstance.next_free_change_address_index; + throw new Error('Not initialized'); + } + + async getChangeAddressAsync() { + if (this._hdWalletInstance) return this._hdWalletInstance.getChangeAddressAsync(); + throw new Error('Not initialized'); + } + + async fetchUtxo() { + if (this._hdWalletInstance) return this._hdWalletInstance.fetchUtxo(); + // Single-address watch-only uses LegacyWallet UTXO + derivation from txs (no HD instance). + return super.fetchUtxo(); + } + + getUtxo(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.getUtxo(...args); + return super.getUtxo(...args); + } + + // Same path as createTransaction — needed so SendDetails fee matches the PSBT (#8803) + coinselect(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.coinselect(...args); + return super.coinselect(...args); + } + + combinePsbt(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.combinePsbt(...args); + throw new Error('Not initialized'); + } + + broadcastTx(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.broadcastTx(...args); + throw new Error('Not initialized'); + } + + /** + * signature of this method is the same ad BIP84 createTransaction, BUT this method should be used to create + * unsinged PSBT to be used with HW wallet (or other external signer) + */ + createTransaction(...args: Parameters) { + const [utxos, targets, feeRate, changeAddress, sequence] = args; + if (this._hdWalletInstance && this.isHd()) { + const masterFingerprint = this.getMasterFingerprint(); + return this._hdWalletInstance.createTransaction(utxos, targets, feeRate, changeAddress, sequence, true, masterFingerprint); + } else { + throw new Error('Not a HD watch-only wallet, cant create PSBT (or just not initialized)'); + } + } + + getMasterFingerprint() { + return this.masterFingerprint; + } + + getMasterFingerprintHex() { + if (!this.masterFingerprint) return '00000000'; + let masterFingerprintHex = Number(this.masterFingerprint).toString(16); + if (masterFingerprintHex.length < 8) masterFingerprintHex = '0' + masterFingerprintHex; // conversion without explicit zero might result in lost byte + // poor man's little-endian conversion: + // ¯\_(ツ)_/¯ + return ( + masterFingerprintHex[6] + + masterFingerprintHex[7] + + masterFingerprintHex[4] + + masterFingerprintHex[5] + + masterFingerprintHex[2] + + masterFingerprintHex[3] + + masterFingerprintHex[0] + + masterFingerprintHex[1] + ); + } + + isHd() { + return this.secret.startsWith('xpub') || this.secret.startsWith('ypub') || this.secret.startsWith('zpub'); + } + + weOwnAddress(address: string) { + if (this.isHd()) { + if (this._hdWalletInstance) return this._hdWalletInstance.weOwnAddress(address); + throw new Error('Not initialized'); + } + + if (address && address.startsWith('BC1')) address = address.toLowerCase(); + + return this.getAddress() === address; + } + + allowMasterFingerprint() { + return this.isHd(); + } + + useWithHardwareWalletEnabled() { + return !!this.use_with_hardware_wallet; + } + + setUseWithHardwareWalletEnabled(enabled: boolean) { + this.use_with_hardware_wallet = !!enabled; + } + + /** + * @inheritDoc + */ + getAllExternalAddresses() { + if (this._hdWalletInstance) return this._hdWalletInstance.getAllExternalAddresses(); + return super.getAllExternalAddresses(); + } + + isXpubValid() { + let xpub; + + try { + if (this.secret.startsWith('zpub')) { + xpub = this._zpubToXpub(this.secret); + } else if (this.secret.startsWith('ypub')) { + xpub = AbstractWallet._ypubToXpub(this.secret); + } else { + xpub = this.secret; + } + + const hdNode = bip32.fromBase58(xpub); + hdNode.derive(0); + return true; + } catch (_) {} + + return false; + } + + addressIsChange(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.addressIsChange(...args); + return super.addressIsChange(...args); + } + + getUTXOMetadata(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.getUTXOMetadata(...args); + return super.getUTXOMetadata(...args); + } + + setUTXOMetadata(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.setUTXOMetadata(...args); + return super.setUTXOMetadata(...args); + } + + getDerivationPath(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.getDerivationPath(...args); + throw new Error("Not a HD watch-only wallet, can't use derivation path"); + } + + setDerivationPath(...args: Parameters) { + if (this._hdWalletInstance) return this._hdWalletInstance.setDerivationPath(...args); + throw new Error("Not a HD watch-only wallet, can't use derivation path"); + } + + isSegwit(): boolean { + if (this._hdWalletInstance) return this._hdWalletInstance.isSegwit(); + return super.isSegwit(); + } + + wasEverUsed(): Promise { + if (this._hdWalletInstance) return this._hdWalletInstance.wasEverUsed(); + return super.wasEverUsed(); + } +} diff --git a/codegen/NativeEventEmitter.ts b/codegen/NativeEventEmitter.ts new file mode 100644 index 00000000000..30cfdc1b303 --- /dev/null +++ b/codegen/NativeEventEmitter.ts @@ -0,0 +1,14 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +import type { Double, UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; + +export interface Spec extends TurboModule { + addListener(eventName: string): void; + removeListeners(count: Double): void; + getMostRecentUserActivity(): Promise; +} + +const moduleProxy = TurboModuleRegistry.getEnforcing('EventEmitter'); + +export default moduleProxy; diff --git a/codegen/NativeMenuElementsEmitter.ts b/codegen/NativeMenuElementsEmitter.ts new file mode 100644 index 00000000000..7939b1719b1 --- /dev/null +++ b/codegen/NativeMenuElementsEmitter.ts @@ -0,0 +1,18 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +import type { Double } from 'react-native/Libraries/Types/CodegenTypes'; + +export interface Spec extends TurboModule { + addListener(eventName: string): void; + removeListeners(count: Double): void; + openSettings(): void; + addWalletMenuAction(): void; + importWalletMenuAction(): void; + reloadTransactionsMenuAction(): void; + sharedInstance?(): void; +} + +const moduleProxy = TurboModuleRegistry.getEnforcing('MenuElementsEmitter'); + +export default moduleProxy; diff --git a/codegen/NativeSettingsModule.ts b/codegen/NativeSettingsModule.ts new file mode 100644 index 00000000000..a358114bad4 --- /dev/null +++ b/codegen/NativeSettingsModule.ts @@ -0,0 +1,17 @@ +import { TurboModuleRegistry } from 'react-native'; +import type { TurboModule } from 'react-native'; + +export interface Spec extends TurboModule { + initializeDeviceUID(): Promise; + getDeviceUID(): Promise; + getDeviceUIDCopy(): Promise; + setClearFilesOnLaunch(value: boolean): Promise; + getClearFilesOnLaunch(): Promise; + setDoNotTrack(enabled: boolean): Promise; + getDoNotTrack(): Promise; + openSettings(): Promise; +} + +const nativeModule = TurboModuleRegistry.get('SettingsModule'); + +export default nativeModule; diff --git a/codegen/NativeWidgetHelper.ts b/codegen/NativeWidgetHelper.ts new file mode 100644 index 00000000000..36828bbb96e --- /dev/null +++ b/codegen/NativeWidgetHelper.ts @@ -0,0 +1,10 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + reloadAllWidgets(): void; +} + +const moduleProxy = TurboModuleRegistry.getEnforcing('WidgetHelper'); + +export default moduleProxy; diff --git a/codegen/SegmentedControlNativeComponent.ts b/codegen/SegmentedControlNativeComponent.ts new file mode 100644 index 00000000000..6d589fadd8c --- /dev/null +++ b/codegen/SegmentedControlNativeComponent.ts @@ -0,0 +1,23 @@ +import type { HostComponent } from 'react-native'; +import type { ViewProps } from 'react-native'; +import type { BubblingEventHandler, Int32, WithDefault } from 'react-native/Libraries/Types/CodegenTypes'; +import { codegenNativeComponent } from 'react-native'; + + +type SegmentedControlChangeEvent = Readonly<{ + selectedIndex: Int32; + target: Int32; +}>; + +export interface NativeProps extends ViewProps { + values?: ReadonlyArray; + selectedIndex?: WithDefault; + enabled?: WithDefault; + backgroundColor?: string | null; + tintColor?: string | null; + textColor?: string | null; + momentary?: WithDefault; + onChange?: BubblingEventHandler | null; +} + +export default codegenNativeComponent('SegmentedControl') as HostComponent; diff --git a/components/AddWalletButton.tsx b/components/AddWalletButton.tsx new file mode 100644 index 00000000000..42d4b0a4b94 --- /dev/null +++ b/components/AddWalletButton.tsx @@ -0,0 +1,66 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, GestureResponderEvent, View } from 'react-native'; +import Icon from './Icon'; +import { useTheme } from './themes'; +import ToolTipMenu from './TooltipMenu'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import loc from '../loc'; + +type AddWalletButtonProps = { + onPress: (event: GestureResponderEvent) => void; +}; + +const AddWalletButton: React.FC = ({ onPress }) => { + const { colors } = useTheme(); + const navigation = useNavigation(); + + const onPressMenuItem = useCallback( + (action: string) => { + switch (action) { + case CommonToolTipActions.ImportWallet.id: + navigation.navigate('AddWalletRoot', { screen: 'ImportWallet' }); + break; + default: + break; + } + }, + [navigation], + ); + + const actions = useMemo(() => [CommonToolTipActions.ImportWallet], []); + + return ( + + + + + + ); +}; + +export default AddWalletButton; + +const styles = StyleSheet.create({ + ball: { + width: 32, + height: 32, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + iconContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/components/AddressInput.js b/components/AddressInput.js deleted file mode 100644 index 2a674534df8..00000000000 --- a/components/AddressInput.js +++ /dev/null @@ -1,151 +0,0 @@ -import React, { useRef } from 'react'; -import PropTypes from 'prop-types'; -import { Text } from 'react-native-elements'; -import { findNodeHandle, Image, Keyboard, StyleSheet, TextInput, TouchableOpacity, View } from 'react-native'; -import { getSystemName } from 'react-native-device-info'; -import { useTheme } from '@react-navigation/native'; - -import loc from '../loc'; -import * as NavigationService from '../NavigationService'; -const fs = require('../blue_modules/fs'); - -const isDesktop = getSystemName() === 'Mac OS X'; - -const AddressInput = ({ - isLoading = false, - address = '', - placeholder = loc.send.details_address, - onChangeText, - onBarScanned, - onBarScannerDismissWithoutData = () => {}, - scanButtonTapped = () => {}, - launchedBy, - editable = true, - inputAccessoryViewID, - onBlur = () => {}, - keyboardType = 'default', -}) => { - const { colors } = useTheme(); - const scanButtonRef = useRef(); - - const stylesHook = StyleSheet.create({ - root: { - borderColor: colors.formBorder, - borderBottomColor: colors.formBorder, - backgroundColor: colors.inputBackgroundColor, - }, - scan: { - backgroundColor: colors.scanLabel, - }, - scanText: { - color: colors.inverseForegroundColor, - }, - }); - - const onBlurEditing = () => { - onBlur(); - Keyboard.dismiss(); - }; - - return ( - - - {editable ? ( - { - await scanButtonTapped(); - Keyboard.dismiss(); - if (isDesktop) { - fs.showActionSheet({ anchor: findNodeHandle(scanButtonRef.current) }).then(onBarScanned); - } else { - NavigationService.navigate('ScanQRCodeRoot', { - screen: 'ScanQRCode', - params: { - launchedBy, - onBarScanned, - onBarScannerDismissWithoutData, - }, - }); - } - }} - accessibilityRole="button" - style={[styles.scan, stylesHook.scan]} - accessibilityLabel={loc.send.details_scan} - accessibilityHint={loc.send.details_scan_hint} - > - - - {loc.send.details_scan} - - - ) : null} - - ); -}; - -const styles = StyleSheet.create({ - root: { - flexDirection: 'row', - borderWidth: 1.0, - borderBottomWidth: 0.5, - minHeight: 44, - height: 44, - marginHorizontal: 20, - alignItems: 'center', - marginVertical: 8, - borderRadius: 4, - }, - input: { - flex: 1, - marginHorizontal: 8, - minHeight: 33, - color: '#81868e', - }, - scan: { - height: 36, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderRadius: 4, - paddingVertical: 4, - paddingHorizontal: 8, - marginHorizontal: 4, - }, - scanText: { - marginLeft: 4, - }, -}); - -AddressInput.propTypes = { - isLoading: PropTypes.bool, - onChangeText: PropTypes.func, - onBarScanned: PropTypes.func.isRequired, - launchedBy: PropTypes.string, - address: PropTypes.string, - placeholder: PropTypes.string, - editable: PropTypes.bool, - scanButtonTapped: PropTypes.func, - inputAccessoryViewID: PropTypes.string, - onBarScannerDismissWithoutData: PropTypes.func, - onBlur: PropTypes.func, - keyboardType: PropTypes.string, -}; - -export default AddressInput; diff --git a/components/AddressInput.tsx b/components/AddressInput.tsx new file mode 100644 index 00000000000..af525ec2c0d --- /dev/null +++ b/components/AddressInput.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { StyleProp, StyleSheet, TextInput, View, ViewStyle } from 'react-native'; +import loc from '../loc'; +import { AddressInputScanButton } from './AddressInputScanButton'; +import { useTheme } from './themes'; + +interface AddressInputProps { + isLoading?: boolean; + address?: string; + placeholder?: string; + onChangeText: (text: string) => void; + editable?: boolean; + inputAccessoryViewID?: string; + onFocus?: () => void; + onBlur?: () => void; + testID?: string; + style?: StyleProp; + keyboardType?: + | 'default' + | 'numeric' + | 'email-address' + | 'ascii-capable' + | 'numbers-and-punctuation' + | 'url' + | 'number-pad' + | 'phone-pad' + | 'name-phone-pad' + | 'decimal-pad' + | 'twitter' + | 'web-search' + | 'visible-password'; +} + +const AddressInput = ({ + isLoading = false, + address = '', + testID = 'AddressInput', + placeholder = loc.send.details_address, + onChangeText, + editable = true, + inputAccessoryViewID, + onFocus = () => {}, + onBlur = () => {}, + keyboardType = 'default', + style, +}: AddressInputProps) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + root: { + borderColor: colors.formBorder, + borderBottomColor: colors.formBorder, + backgroundColor: colors.inputBackgroundColor, + }, + input: { + color: colors.foregroundColor, + }, + }); + + return ( + + + {editable ? : null} + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + borderWidth: 1.0, + borderBottomWidth: 0.5, + minHeight: 44, + height: 44, + alignItems: 'center', + borderRadius: 4, + }, + input: { + flex: 1, + paddingHorizontal: 8, + minHeight: 33, + fontSize: 15, + lineHeight: 19, + }, +}); + +export default AddressInput; diff --git a/components/AddressInputScanButton.tsx b/components/AddressInputScanButton.tsx new file mode 100644 index 00000000000..b7f83691f5c --- /dev/null +++ b/components/AddressInputScanButton.tsx @@ -0,0 +1,200 @@ +import React, { useCallback, useMemo } from 'react'; +import { Image, Keyboard, Platform, StyleSheet, Text, View } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; +import ToolTipMenu from './TooltipMenu'; +import loc from '../loc'; +import { showFilePickerAndReadFile, showImagePickerAndReadImage } from '../blue_modules/fs'; +import presentAlert from './Alert'; +import { useTheme } from './themes'; +import { detectQRCodeInImage } from 'react-native-camera-kit-no-google'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import { useSettings } from '../hooks/context/useSettings'; +import { scanQrHelper } from '../helpers/scan-qr.ts'; + +interface AddressInputScanButtonProps { + isLoading?: boolean; + onChangeText: (text: string) => void; + type?: 'default' | 'link'; + testID?: string; + beforePress?: () => Promise | void; +} + +export const AddressInputScanButton = ({ + isLoading, + onChangeText, + type = 'default', + testID = 'BlueAddressInputScanQrButton', + beforePress, +}: AddressInputScanButtonProps) => { + const { colors } = useTheme(); + const { isClipboardGetContentEnabled } = useSettings(); + + const stylesHook = StyleSheet.create({ + scan: { + backgroundColor: colors.scanLabel, + }, + scanText: { + color: colors.inverseForegroundColor, + }, + }); + + const toolTipOnPress = useCallback(async () => { + if (beforePress) { + await beforePress(); + } + Keyboard.dismiss(); + scanQrHelper().then(onChangeText); + }, [beforePress, onChangeText]); + + const actions = useMemo(() => { + const availableActions = [ + CommonToolTipActions.ChoosePhoto, + CommonToolTipActions.ImportFile, + { + ...CommonToolTipActions.PasteFromClipboard, + hidden: !isClipboardGetContentEnabled, + }, + ]; + + return availableActions; + }, [isClipboardGetContentEnabled]); + + const onMenuItemPressed = useCallback( + async (action: string) => { + switch (action) { + case CommonToolTipActions.PasteFromClipboard.id: + try { + let getImage: string | null = null; + let hasImage = false; + if (Platform.OS === 'android') { + hasImage = true; + } else { + hasImage = await Clipboard.hasImage(); + } + + if (hasImage) { + getImage = await Clipboard.getImage(); + } + + if (getImage) { + try { + const base64Data = getImage.replace(/^data:image\/(png|jpeg|jpg);base64,/, ''); + const result = await detectQRCodeInImage(base64Data); + if (result) { + onChangeText(result); + } else { + presentAlert({ message: loc.send.qr_error_no_qrcode }); + } + } catch (error) { + presentAlert({ message: (error as Error).message }); + } + } else { + const clipboardText = await Clipboard.getString(); + onChangeText(clipboardText); + } + } catch (error) { + presentAlert({ message: (error as Error).message }); + } + break; + case CommonToolTipActions.ChoosePhoto.id: + showImagePickerAndReadImage() + .then(value => { + if (value) { + onChangeText(value); + } + }) + .catch(error => { + presentAlert({ message: error.message }); + }); + break; + case CommonToolTipActions.ImportFile.id: + showFilePickerAndReadFile() + .then(value => { + if (value.data) { + onChangeText(value.data); + } + }) + .catch(error => { + presentAlert({ message: error.message }); + }); + break; + } + Keyboard.dismiss(); + }, + [onChangeText], + ); + + const menuButtonStyle = useMemo(() => (type === 'default' ? [styles.scan, stylesHook.scan] : undefined), [stylesHook.scan, type]); + + return ( + + {type === 'default' ? ( + + + + {loc.send.details_scan} + + + ) : ( + + + {loc.wallets.import_scan_qr} + + + )} + + ); +}; + +AddressInputScanButton.displayName = 'AddressInputScanButton'; + +const styles = StyleSheet.create({ + scan: { + height: 36, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + minWidth: 82, + flexShrink: 0, + borderRadius: 4, + paddingVertical: 4, + paddingHorizontal: 8, + marginHorizontal: 4, + alignSelf: 'center', + }, + scanText: { + marginLeft: 4, + flexShrink: 0, + textAlignVertical: 'center', + }, + scanContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + }, + linkText: { + textAlign: 'center', + fontSize: 16, + flexShrink: 1, + textAlignVertical: 'center', + }, + contentRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + }, +}); diff --git a/components/Alert.js b/components/Alert.js deleted file mode 100644 index 44fcc6a2f24..00000000000 --- a/components/Alert.js +++ /dev/null @@ -1,6 +0,0 @@ -import { Alert } from 'react-native'; -import loc from '../loc'; -const alert = string => { - Alert.alert(loc.alert.default, string); -}; -export default alert; diff --git a/components/Alert.ts b/components/Alert.ts new file mode 100644 index 00000000000..8c2bbc0f2b7 --- /dev/null +++ b/components/Alert.ts @@ -0,0 +1,85 @@ +import { Alert as RNAlert, Platform, ToastAndroid, AlertButton, AlertOptions } from 'react-native'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; +import { navigationRef } from '../NavigationService'; + +export enum AlertType { + Alert, + Toast, +} + +const presentAlert = (() => { + let lastAlertParams: { + title?: string; + message: string; + type?: AlertType; + hapticFeedback?: HapticFeedbackTypes; + buttons?: AlertButton[]; + options?: AlertOptions; + } | null = null; + + const clearCache = () => { + lastAlertParams = null; + }; + + const showAlert = (title: string | undefined, message: string, buttons: AlertButton[], options: AlertOptions) => { + if (Platform.OS === 'ios' && navigationRef.isReady()) { + RNAlert.alert(title ?? message, title && message ? message : undefined, buttons, options); + } else { + RNAlert.alert(title ?? '', message, buttons, options); + } + }; + + return ({ + title, + message, + type = AlertType.Alert, + hapticFeedback, + buttons = [], + options = { cancelable: false }, + allowRepeat = true, + }: { + title?: string; + message: string; + type?: AlertType; + hapticFeedback?: HapticFeedbackTypes; + buttons?: AlertButton[]; + options?: AlertOptions; + allowRepeat?: boolean; + }) => { + const currentAlertParams = { title, message, type, hapticFeedback, buttons, options }; + + if (!allowRepeat && lastAlertParams && JSON.stringify(lastAlertParams) === JSON.stringify(currentAlertParams)) { + return; + } + + if (JSON.stringify(lastAlertParams) !== JSON.stringify(currentAlertParams)) { + clearCache(); + } + + lastAlertParams = currentAlertParams; + + if (hapticFeedback) { + triggerHapticFeedback(hapticFeedback); + } + + const wrappedButtons: AlertButton[] = buttons.length > 0 ? buttons : [{ text: loc._.ok, onPress: () => {}, style: 'default' }]; + + switch (type) { + case AlertType.Toast: + if (Platform.OS === 'android') { + ToastAndroid.show(message, ToastAndroid.LONG); + clearCache(); + } else { + // For iOS, treat Toast as a normal alert + showAlert(title, message, wrappedButtons, options); + } + break; + default: + showAlert(title, message, wrappedButtons, options); + break; + } + }; +})(); + +export default presentAlert; diff --git a/components/AmountInput.js b/components/AmountInput.js deleted file mode 100644 index 2c67edc528d..00000000000 --- a/components/AmountInput.js +++ /dev/null @@ -1,409 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import BigNumber from 'bignumber.js'; -import { Badge, Icon, Text } from 'react-native-elements'; -import { Image, LayoutAnimation, Pressable, StyleSheet, TextInput, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'; -import { useTheme } from '@react-navigation/native'; -import confirm from '../helpers/confirm'; -import { BitcoinUnit } from '../models/bitcoinUnits'; -import loc, { formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros } from '../loc'; -import { BlueText } from '../BlueComponents'; -import dayjs from 'dayjs'; -const currency = require('../blue_modules/currency'); -dayjs.extend(require('dayjs/plugin/localizedFormat')); - -class AmountInput extends Component { - static propTypes = { - isLoading: PropTypes.bool, - /** - * amount is a sting thats always in current unit denomination, e.g. '0.001' or '9.43' or '10000' - */ - amount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** - * callback that returns currently typed amount, in current denomination, e.g. 0.001 or 10000 or $9.34 - * (btc, sat, fiat) - */ - onChangeText: PropTypes.func.isRequired, - /** - * callback thats fired to notify of currently selected denomination, returns - */ - onAmountUnitChange: PropTypes.func.isRequired, - disabled: PropTypes.bool, - colors: PropTypes.object.isRequired, - pointerEvents: PropTypes.string, - unit: PropTypes.string, - onBlur: PropTypes.func, - onFocus: PropTypes.func, - }; - - /** - * cache of conversions fiat amount => satoshi - * @type {{}} - */ - static conversionCache = {}; - - static getCachedSatoshis = amount => { - return AmountInput.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] || false; - }; - - static setCachedSatoshis = (amount, sats) => { - AmountInput.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] = sats; - }; - - constructor() { - super(); - this.state = { mostRecentFetchedRate: Date(), isRateOutdated: false, isRateBeingUpdated: false }; - } - - componentDidMount() { - currency - .mostRecentFetchedRate() - .then(mostRecentFetchedRate => { - this.setState({ mostRecentFetchedRate }); - }) - .finally(() => { - currency.isRateOutdated().then(isRateOutdated => this.setState({ isRateOutdated })); - }); - } - - /** - * here we must recalculate old amont value (which was denominated in `previousUnit`) to new denomination `newUnit` - * and fill this value in input box, so user can switch between, for example, 0.001 BTC <=> 100000 sats - * - * @param previousUnit {string} one of {BitcoinUnit.*} - * @param newUnit {string} one of {BitcoinUnit.*} - */ - onAmountUnitChange(previousUnit, newUnit) { - const amount = this.props.amount || 0; - const log = `${amount}(${previousUnit}) ->`; - let sats = 0; - switch (previousUnit) { - case BitcoinUnit.BTC: - sats = new BigNumber(amount).multipliedBy(100000000).toString(); - break; - case BitcoinUnit.SATS: - sats = amount; - break; - case BitcoinUnit.LOCAL_CURRENCY: - sats = new BigNumber(currency.fiatToBTC(amount)).multipliedBy(100000000).toString(); - break; - } - if (previousUnit === BitcoinUnit.LOCAL_CURRENCY && AmountInput.conversionCache[amount + previousUnit]) { - // cache hit! we reuse old value that supposedly doesnt have rounding errors - sats = AmountInput.conversionCache[amount + previousUnit]; - } - - const newInputValue = formatBalancePlain(sats, newUnit, false); - console.log(`${log} ${sats}(sats) -> ${newInputValue}(${newUnit})`); - - if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) { - // we cache conversion, so when we will need reverse conversion there wont be a rounding error - AmountInput.conversionCache[newInputValue + newUnit] = amount; - } - this.props.onChangeText(newInputValue); - this.props.onAmountUnitChange(newUnit); - } - - /** - * responsible for cycling currently selected denomination, BTC->SAT->LOCAL_CURRENCY->BTC - */ - changeAmountUnit = () => { - let previousUnit = this.props.unit; - let newUnit; - if (previousUnit === BitcoinUnit.BTC) { - newUnit = BitcoinUnit.SATS; - } else if (previousUnit === BitcoinUnit.SATS) { - newUnit = BitcoinUnit.LOCAL_CURRENCY; - } else if (previousUnit === BitcoinUnit.LOCAL_CURRENCY) { - newUnit = BitcoinUnit.BTC; - } else { - newUnit = BitcoinUnit.BTC; - previousUnit = BitcoinUnit.SATS; - } - this.onAmountUnitChange(previousUnit, newUnit); - }; - - maxLength = () => { - switch (this.props.unit) { - case BitcoinUnit.BTC: - return 11; - case BitcoinUnit.SATS: - return 15; - default: - return 15; - } - }; - - textInput = React.createRef(); - - handleTextInputOnPress = () => { - this.textInput.current.focus(); - }; - - handleChangeText = text => { - text = text.trim(); - if (this.props.unit !== BitcoinUnit.LOCAL_CURRENCY) { - text = text.replace(',', '.'); - const split = text.split('.'); - if (split.length >= 2) { - text = `${parseInt(split[0], 10)}.${split[1]}`; - } else { - text = `${parseInt(split[0], 10)}`; - } - - text = this.props.unit === BitcoinUnit.BTC ? text.replace(/[^0-9.]/g, '') : text.replace(/[^0-9]/g, ''); - - if (text.startsWith('.')) { - text = '0.'; - } - } else if (this.props.unit === BitcoinUnit.LOCAL_CURRENCY) { - text = text.replace(/,/gi, '.'); - if (text.split('.').length > 2) { - // too many dots. stupid code to remove all but first dot: - let rez = ''; - let first = true; - for (const part of text.split('.')) { - rez += part; - if (first) { - rez += '.'; - first = false; - } - } - text = rez; - } - if (text.startsWith('0') && !(text.includes('.') || text.includes(','))) { - text = text.replace(/^(0+)/g, ''); - } - text = text.replace(/[^\d.,-]/g, ''); // remove all but numbers, dots & commas - text = text.replace(/(\..*)\./g, '$1'); - } - this.props.onChangeText(text); - }; - - resetAmount = async () => { - if (await confirm(loc.send.reset_amount, loc.send.reset_amount_confirm)) { - this.props.onChangeText(); - } - }; - - updateRate = () => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState({ isRateBeingUpdated: true }, async () => { - try { - await currency.updateExchangeRate(); - currency.mostRecentFetchedRate().then(mostRecentFetchedRate => { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState({ mostRecentFetchedRate }); - }); - } finally { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState({ isRateBeingUpdated: false, isRateOutdated: await currency.isRateOutdated() }); - } - }); - }; - - render() { - const { colors, disabled, unit } = this.props; - const amount = this.props.amount || 0; - let secondaryDisplayCurrency = formatBalanceWithoutSuffix(amount, BitcoinUnit.LOCAL_CURRENCY, false); - - // if main display is sat or btc - secondary display is fiat - // if main display is fiat - secondary dislay is btc - let sat; - switch (unit) { - case BitcoinUnit.BTC: - sat = new BigNumber(amount).multipliedBy(100000000).toString(); - secondaryDisplayCurrency = formatBalanceWithoutSuffix(sat, BitcoinUnit.LOCAL_CURRENCY, false); - break; - case BitcoinUnit.SATS: - secondaryDisplayCurrency = formatBalanceWithoutSuffix((isNaN(amount) ? 0 : amount).toString(), BitcoinUnit.LOCAL_CURRENCY, false); - break; - case BitcoinUnit.LOCAL_CURRENCY: - secondaryDisplayCurrency = currency.fiatToBTC(parseFloat(isNaN(amount) ? 0 : amount)); - if (AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY]) { - // cache hit! we reuse old value that supposedly doesn't have rounding errors - const sats = AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY]; - secondaryDisplayCurrency = currency.satoshiToBTC(sats); - } - break; - } - - if (amount === BitcoinUnit.MAX) secondaryDisplayCurrency = ''; // we don't want to display NaN - - const stylesHook = StyleSheet.create({ - center: { padding: amount === BitcoinUnit.MAX ? 0 : 15 }, - localCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 }, - input: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2, fontSize: amount.length > 10 ? 20 : 36 }, - cryptoCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 }, - }); - - return ( - this.textInput.focus()} - > - <> - - {!disabled && } - - - {unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && ( - {currency.getCurrencySymbol() + ' '} - )} - {amount !== BitcoinUnit.MAX ? ( - { - if (this.props.onBlur) this.props.onBlur(); - }} - onFocus={() => { - if (this.props.onFocus) this.props.onFocus(); - }} - placeholder="0" - maxLength={this.maxLength()} - ref={textInput => (this.textInput = textInput)} - editable={!this.props.isLoading && !disabled} - value={amount === BitcoinUnit.MAX ? loc.units.MAX : parseFloat(amount) >= 0 ? String(amount) : undefined} - placeholderTextColor={disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2} - style={[styles.input, stylesHook.input]} - /> - ) : ( - - {BitcoinUnit.MAX} - - )} - {unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && ( - {' ' + loc.units[unit]} - )} - - - - {unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX - ? removeTrailingZeros(secondaryDisplayCurrency) - : secondaryDisplayCurrency} - {unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX ? ` ${loc.units[BitcoinUnit.BTC]}` : null} - - - - {!disabled && amount !== BitcoinUnit.MAX && ( - - - - )} - - {this.state.isRateOutdated && ( - - - - - {loc.formatString(loc.send.outdated_rate, { date: dayjs(this.state.mostRecentFetchedRate.LastUpdated).format('l LT') })} - - - - - - - )} - - - ); - } -} - -const styles = StyleSheet.create({ - root: { - flexDirection: 'row', - justifyContent: 'space-between', - }, - center: { - alignSelf: 'center', - }, - flex: { - flex: 1, - }, - spacing8: { - width: 8, - }, - disabledButton: { - opacity: 0.5, - }, - enabledButon: { - opacity: 1, - }, - outdatedRateContainer: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - marginVertical: 8, - }, - container: { - flexDirection: 'row', - alignContent: 'space-between', - justifyContent: 'center', - paddingTop: 16, - paddingBottom: 2, - }, - localCurrency: { - fontSize: 18, - marginHorizontal: 4, - fontWeight: 'bold', - alignSelf: 'center', - justifyContent: 'center', - }, - input: { - fontWeight: 'bold', - }, - cryptoCurrency: { - fontSize: 15, - marginHorizontal: 4, - fontWeight: '600', - alignSelf: 'center', - justifyContent: 'center', - }, - secondaryRoot: { - alignItems: 'center', - marginBottom: 22, - }, - secondaryText: { - fontSize: 16, - color: '#9BA0A9', - fontWeight: '600', - }, - changeAmountUnit: { - alignSelf: 'center', - marginRight: 16, - paddingLeft: 16, - paddingVertical: 16, - }, -}); - -const AmountInputWithStyle = props => { - const { colors } = useTheme(); - - return ; -}; - -// expose static methods -AmountInputWithStyle.conversionCache = AmountInput.conversionCache; -AmountInputWithStyle.getCachedSatoshis = AmountInput.getCachedSatoshis; -AmountInputWithStyle.setCachedSatoshis = AmountInput.setCachedSatoshis; - -export default AmountInputWithStyle; diff --git a/components/AmountInput.tsx b/components/AmountInput.tsx new file mode 100644 index 00000000000..4a84004df96 --- /dev/null +++ b/components/AmountInput.tsx @@ -0,0 +1,595 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import BigNumber from 'bignumber.js'; +import dayjs from 'dayjs'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Image, + Platform, + Pressable, + StyleSheet, + Text, + TextInput, + TextInputProps, + TextInputSelectionChangeEvent, + TouchableOpacity, + View, +} from 'react-native'; +import Animated, { Easing, FadeIn, FadeOut, LinearTransition } from 'react-native-reanimated'; + +import { + CurrencyRate, + fiatToBTC, + getCurrencySymbol, + isRateOutdated, + mostRecentFetchedRate, + satoshiToBTC, + updateExchangeRate, +} from '../blue_modules/currency'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import confirm from '../helpers/confirm'; +import loc, { formatBalancePlain, formatBalanceWithoutSuffix, removeTrailingZeros } from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import Badge from './Badge'; +import BlueText from './BlueText'; +import Icon from './Icon'; +import { useTheme } from './themes'; + +export const conversionCache: { [key: string]: string } = {}; + +export const getCachedSatoshis = (amount: string): string | undefined => { + return conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY]; +}; + +export const setCachedSatoshis = (amount: string, sats: string): void => { + conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] = sats; +}; + +const INPUT_HORIZONTAL_PADDING = 6; +const INPUT_VERTICAL_PADDING = 2; +const MAX_INPUT_WIDTH = 320; +const CRYPTO_CONTAINER_OFFSET = -12; +const SWAP_ICON_SIZE = 24; +const CHAR_FADE_IN_DURATION_MS = 240; +const CHAR_FADE_OUT_DURATION_MS = 160; +const CHAR_LAYOUT_DURATION_MS = 180; +const SIZER_LAYOUT_DURATION_MS = 200; + +const androidFontPaddingStyle = Platform.OS === 'android' ? { includeFontPadding: false } : null; + +const sizerLayoutTransition = LinearTransition.duration(SIZER_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad)); +const charLayoutTransition = LinearTransition.duration(CHAR_LAYOUT_DURATION_MS).easing(Easing.out(Easing.quad)); +const charEntering = FadeIn.duration(CHAR_FADE_IN_DURATION_MS); +const charExiting = FadeOut.duration(CHAR_FADE_OUT_DURATION_MS); + +type AmountInputProps = Omit & { + /** + * Whether the input is in a loading state + */ + isLoading?: boolean; + /** + * Whether the input is disabled + */ + disabled?: boolean; + /** + * The current amount value as a string in the current unit denomination + * e.g. '0.001' or '9.43' or '10000' + */ + amount?: string; + /** + * The current unit of the amount (BTC, SATS, LOCAL_CURRENCY) + */ + unit: BitcoinUnit; + /** + * Callback that returns currently typed amount in current denomination + * e.g. 0.001 or 10000 or $9.34 (btc, sat, fiat) + */ + onChangeText: (text: string) => void; + /** + * Callback that's fired to notify of currently selected denomination + * Returns a BitcoinUnit value + */ + onAmountUnitChange: (unit: BitcoinUnit) => void; + /** + * Estimated sendable amount in satoshis when MAX is selected. + * Displayed below the MAX label. Pass null to hide. + */ + maxSendableAmount?: number | null; + /** + * When true, shows ≈ prefix for maxSendableAmount (indicates estimate). + */ + isMaxAmountEstimate?: boolean; +}; + +export const AmountInput: React.FC = props => { + const textInputRef = useRef(null); + const { colors } = useTheme(); + const amount = props.amount || '0'; // internally amount is aways a string with a correct number + const { + onChangeText, + unit, + onAmountUnitChange, + disabled = false, + isLoading = false, + maxSendableAmount, + isMaxAmountEstimate, + style: styleOverride, + ...otherProps + } = props; + const [isRateBeingUpdatedLocal, setIsRateBeingUpdatedLocal] = useState(false); + const [outdatedRefreshRate, setOutdatedRefreshRate] = useState(); + + const maxLength = useMemo(() => { + switch (unit) { + case BitcoinUnit.BTC: + return 11; + case BitcoinUnit.SATS: + return 15; + default: + return 15; + } + }, [unit]); + + const displayAmount = useMemo(() => { + if (amount === BitcoinUnit.MAX) { + return loc.units.MAX; + } + + return parseFloat(amount) >= 0 ? String(amount) : undefined; + }, [amount]); + + const inputFontSize = useMemo(() => (amount.length > 10 ? 20 : 36), [amount.length]); + + const measureAmountText = displayAmount && displayAmount.length > 0 ? displayAmount : '0'; + + const inputTextAlign = useMemo((): 'left' | 'right' | 'center' => { + if (amount === BitcoinUnit.MAX) return 'center'; + return unit === BitcoinUnit.LOCAL_CURRENCY ? 'left' : 'right'; + }, [amount, unit]); + + const secondaryDisplayCurrency = useMemo(() => { + if (amount === BitcoinUnit.MAX) { + return ''; + } + switch (unit) { + case BitcoinUnit.BTC: { + const sat = new BigNumber(amount).multipliedBy(100000000).toNumber(); + return formatBalanceWithoutSuffix(sat, BitcoinUnit.LOCAL_CURRENCY, false); + } + case BitcoinUnit.SATS: + return formatBalanceWithoutSuffix(Number(amount), BitcoinUnit.LOCAL_CURRENCY, false); + case BitcoinUnit.LOCAL_CURRENCY: { + let res: string = ''; + if (conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY]) { + // cache hit! we reuse old value that supposedly doesn't have rounding errors + const sats = conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY]; + res = satoshiToBTC(Number(sats)); + } else { + res = fiatToBTC(Number(amount)); + } + res = removeTrailingZeros(res); + return `${res} ${loc.units[BitcoinUnit.BTC]}`; + } + } + }, [amount, unit]); + + useEffect(() => { + (async () => { + if (await isRateOutdated()) { + const recent = await mostRecentFetchedRate(); + setOutdatedRefreshRate(recent); + } + })(); + }, []); + + const updateRate = useCallback(async () => { + try { + await updateExchangeRate(); + } finally { + setIsRateBeingUpdatedLocal(false); + if (await isRateOutdated()) { + const recent = await mostRecentFetchedRate(); + setOutdatedRefreshRate(recent); + } else { + setOutdatedRefreshRate(undefined); + } + } + }, []); + + const changeAmountUnit = useCallback(() => { + let previousUnit = unit; + let newUnit; + // cycle through units BTC -> SAT -> LOCAL_CURRENCY -> BTC + if (previousUnit === BitcoinUnit.BTC) { + newUnit = BitcoinUnit.SATS; + } else if (previousUnit === BitcoinUnit.SATS) { + newUnit = BitcoinUnit.LOCAL_CURRENCY; + } else if (previousUnit === BitcoinUnit.LOCAL_CURRENCY) { + newUnit = BitcoinUnit.BTC; + } else { + newUnit = BitcoinUnit.BTC; + previousUnit = BitcoinUnit.SATS; + } + + /** + * here we must recalculate old amont value (which was denominated in `previousUnit`) to new denomination `newUnit` + * and fill this value in input box, so user can switch between, for example, 0.001 BTC <=> 100000 sats + */ + let sats: string = '0'; + switch (previousUnit) { + case BitcoinUnit.BTC: + sats = new BigNumber(amount).multipliedBy(100000000).toString(); + break; + case BitcoinUnit.SATS: + sats = amount; + break; + case BitcoinUnit.LOCAL_CURRENCY: + sats = new BigNumber(fiatToBTC(+amount)).multipliedBy(100000000).toString(); + break; + } + if (previousUnit === BitcoinUnit.LOCAL_CURRENCY && conversionCache[amount + previousUnit]) { + // cache hit! we reuse old value that supposedly doesnt have rounding errors + sats = conversionCache[amount + previousUnit]; + } + + const newInputValue = formatBalancePlain(+sats, newUnit, false); + + if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) { + // we cache conversion, so when we will need reverse conversion there wont be a rounding error + conversionCache[newInputValue + newUnit] = amount; + } + onChangeText(newInputValue); + onAmountUnitChange(newUnit); + }, [amount, onChangeText, onAmountUnitChange, unit]); + + const handleTextInputOnPress = useCallback(() => { + textInputRef?.current?.focus(); + }, []); + + const handleChangeText = useCallback( + (text: string) => { + text = text.trim(); + if (unit !== BitcoinUnit.LOCAL_CURRENCY) { + text = text.replace(',', '.'); + const split = text.split('.'); + if (split.length >= 2) { + text = `${parseInt(split[0], 10)}.${split[1]}`; + } else { + text = `${parseInt(split[0], 10)}`; + } + + text = unit === BitcoinUnit.BTC ? text.replace(/[^0-9.]/g, '') : text.replace(/[^0-9]/g, ''); + } else { + text = text.replace(/,/gi, '.'); + if (text.split('.').length > 2) { + // too many dots. stupid code to remove all but first dot: + let rez = ''; + let first = true; + for (const part of text.split('.')) { + rez += part; + if (first) { + rez += '.'; + first = false; + } + } + text = rez; + } + if (text.startsWith('0') && !(text.includes('.') || text.includes(','))) { + text = text.replace(/^(0+)/g, ''); + } + text = text.replace(/[^\d.,-]/g, ''); // remove all but numbers, dots & commas + text = text.replace(/(\..*)\./g, '$1'); + } + if (text.startsWith('.')) { + text = '0.'; + } + onChangeText(text); + }, + [onChangeText, unit], + ); + + const resetAmount = useCallback(async () => { + if (await confirm(loc.send.reset_amount, loc.send.reset_amount_confirm)) { + onChangeText('0'); + } + }, [onChangeText]); + + const copyMaxEstimate = useCallback(() => { + if (maxSendableAmount == null) return; + const btcValue = removeTrailingZeros(new BigNumber(maxSendableAmount).dividedBy(100000000).toFixed(8)); + Clipboard.setString(btcValue); + triggerHapticFeedback(HapticFeedbackTypes.Selection); + }, [maxSendableAmount]); + + const handleSelectionChange = useCallback( + (event: TextInputSelectionChangeEvent) => { + const { selection } = event.nativeEvent; + if (selection.start !== selection.end || selection.start !== amount.length) { + textInputRef.current?.setNativeProps({ selection: { start: amount.length, end: amount.length } }); + } + }, + [amount], + ); + + const isCryptoUnit = unit !== BitcoinUnit.LOCAL_CURRENCY; + + const amountCharacters = useMemo(() => measureAmountText.split(''), [measureAmountText]); + + const displayJustifyContent = useMemo((): 'flex-start' | 'flex-end' | 'center' => { + if (inputTextAlign === 'right') return 'flex-end'; + if (inputTextAlign === 'left') return 'flex-start'; + return 'center'; + }, [inputTextAlign]); + + const inputTextColor = disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2; + const hiddenInputTextColor = Platform.OS === 'android' ? `${inputTextColor}00` : 'transparent'; + + const inputTypography = { + fontSize: inputFontSize, + lineHeight: Math.round(inputFontSize * 1.15), + minHeight: Math.round(inputFontSize * 1.15) + INPUT_VERTICAL_PADDING * 2, + textAlign: inputTextAlign, + ...(isCryptoUnit && { + paddingLeft: INPUT_HORIZONTAL_PADDING + 4, + }), + }; + + const stylesHook = { + container: { + marginLeft: unit === BitcoinUnit.LOCAL_CURRENCY ? 0 : CRYPTO_CONTAINER_OFFSET, + }, + localCurrency: { color: inputTextColor }, + input: { + color: inputTextColor, + ...inputTypography, + }, + inputDisplay: { + justifyContent: displayJustifyContent, + ...(isCryptoUnit && { + paddingLeft: INPUT_HORIZONTAL_PADDING + 4, + }), + }, + inputGlyph: { + color: inputTextColor, + fontSize: inputTypography.fontSize, + lineHeight: inputTypography.lineHeight, + }, + inputTransparent: { + color: hiddenInputTextColor, + }, + cryptoCurrency: { color: inputTextColor }, + }; + + return ( + + + {!disabled && } + + + {unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && ( + {getCurrencySymbol()} + )} + {amount !== BitcoinUnit.MAX ? ( + + + {measureAmountText} + + + {amountCharacters.map((char, index) => ( + + {char} + + ))} + + + + ) : ( + + + {BitcoinUnit.MAX} + + {maxSendableAmount != null && ( + + {(isMaxAmountEstimate ? '≈ ' : '') + + removeTrailingZeros(new BigNumber(maxSendableAmount).dividedBy(100000000).toFixed(8)) + + ' ' + + loc.units[BitcoinUnit.BTC]} + + )} + + )} + {unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && ( + {loc.units[unit]} + )} + + + + {secondaryDisplayCurrency} + + + + {!disabled && + (amount !== BitcoinUnit.MAX ? ( + + + + ) : ( + + ))} + + {outdatedRefreshRate && ( + + + + {loc.formatString(loc.send.outdated_rate, { date: dayjs(outdatedRefreshRate.LastUpdated).format('l LT') })} + + + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + justifyContent: 'space-between', + }, + flex: { + flex: 1, + overflow: 'visible', + }, + sideRail: { + width: SWAP_ICON_SIZE, + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + }, + spacing8: { + width: 8, + }, + warningBadge: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: '#fc990e', + }, + disabledButton: { + opacity: 0.5, + }, + outdatedRateContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + margin: 16, + }, + container: { + flexDirection: 'row', + alignItems: 'center', + alignContent: 'space-between', + justifyContent: 'center', + paddingTop: 16, + paddingBottom: 2, + overflow: 'visible', + }, + localCurrency: { + fontSize: 18, + marginRight: 2, + fontWeight: 'bold', + alignSelf: 'center', + justifyContent: 'center', + }, + inputSizer: { + maxWidth: MAX_INPUT_WIDTH, + position: 'relative', + overflow: 'visible', + }, + input: { + fontWeight: 'bold', + margin: 0, + borderWidth: 0, + paddingHorizontal: INPUT_HORIZONTAL_PADDING, + paddingVertical: INPUT_VERTICAL_PADDING, + }, + inputGlyph: { + fontWeight: 'bold', + margin: 0, + padding: 0, + }, + inputMeasure: { + opacity: 0, + }, + inputDisplay: { + ...StyleSheet.absoluteFill, + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: INPUT_HORIZONTAL_PADDING, + paddingVertical: INPUT_VERTICAL_PADDING, + zIndex: 1, + }, + inputOverlay: { + ...StyleSheet.absoluteFill, + zIndex: 2, + }, + cryptoCurrency: { + fontSize: 15, + marginLeft: 2, + fontWeight: '600', + alignSelf: 'center', + justifyContent: 'center', + }, + secondaryRoot: { + alignItems: 'center', + marginBottom: 22, + }, + secondaryText: { + fontSize: 16, + color: '#9BA0A9', + fontWeight: '600', + }, + maxEstimate: { + fontSize: 16, + textAlign: 'center', + marginTop: 4, + }, + maxPressable: { + alignItems: 'center', + flexShrink: 0, + }, + maxLabel: { + flexShrink: 0, + }, + changeAmountUnit: { + paddingVertical: 16, + }, +}); diff --git a/components/ArrowPicker.tsx b/components/ArrowPicker.tsx deleted file mode 100644 index 273b9cc2b1a..00000000000 --- a/components/ArrowPicker.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import { StyleSheet, Pressable, View, Keyboard } from 'react-native'; -import { Text, Icon } from 'react-native-elements'; -import React, { useState } from 'react'; -import loc from '../loc'; -import { useTheme } from '@react-navigation/native'; - -interface IHash { - [key: string]: string; -} - -type ArrowPickerProps = { - onChange: (key: string) => void; - items: IHash; - isItemUnknown: boolean; -}; - -export const ArrowPicker = (props: ArrowPickerProps) => { - const keys = Object.keys(props.items); - const [keyIndex, setKeyIndex] = useState(0); - - const { colors } = useTheme(); - - const stylesHook = { - text: { - // @ts-ignore: Ignore theme typescript error - color: colors.foregroundColor, - }, - }; - return ( - - { - Keyboard.dismiss(); - let newIndex = keyIndex; - if (keyIndex <= 0) { - newIndex = keys.length - 1; - } else { - newIndex--; - } - setKeyIndex(newIndex); - props.onChange(keys[newIndex]); - }} - style={({ pressed }) => [ - { - backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white', - }, - styles.wrapperCustom, - ]} - > - {/* -// @ts-ignore: Ignore */} - - - - {props.isItemUnknown ? loc.send.fee_custom : keys[keyIndex]} - - { - Keyboard.dismiss(); - let newIndex = keyIndex; - if (keyIndex + 1 >= keys.length) { - newIndex = 0; - } else { - newIndex++; - } - setKeyIndex(newIndex); - props.onChange(keys[newIndex]); - }} - style={({ pressed }) => [ - { - backgroundColor: pressed ? 'rgb(210, 230, 255)' : 'white', - }, - styles.wrapperCustom, - ]} - > - {/* -// @ts-ignore: Ignore */} - - - - ); -}; - -const styles = StyleSheet.create({ - wrapperCustom: { - borderRadius: 8, - padding: 5, - marginLeft: 20, - marginRight: 20, - }, - text: { fontWeight: 'bold', fontSize: 12, textAlign: 'center' }, -}); diff --git a/components/Avatar.tsx b/components/Avatar.tsx new file mode 100644 index 00000000000..73d688b7650 --- /dev/null +++ b/components/Avatar.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Pressable, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import Icon, { type IconProps } from './Icon'; + +export interface AvatarProps { + rounded?: boolean; + size: number; + containerStyle: StyleProp; + icon?: Pick; + onPress?: () => void; +} + +const Avatar: React.FC = ({ rounded, size, containerStyle, icon, onPress }) => { + const dimensionStyle = { width: size, height: size, borderRadius: rounded ? size / 2 : 0 } as ViewStyle; + const content = ( + + {icon ? : null} + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + return content; +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + }, + pressable: { + alignSelf: 'flex-start', + }, +}); + +export default Avatar; diff --git a/components/Badge.tsx b/components/Badge.tsx new file mode 100644 index 00000000000..18dd770a3e2 --- /dev/null +++ b/components/Badge.tsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { StyleProp, StyleSheet, Text, TextStyle, View, ViewStyle } from 'react-native'; + +export interface BadgeProps { + value?: string | number | React.ReactNode; + badgeStyle?: StyleProp; + textStyle?: StyleProp; + testID?: string; +} + +const Badge: React.FC = ({ value, badgeStyle, textStyle, testID }) => { + return ( + + {typeof value === 'string' || typeof value === 'number' ? {value} : value} + + ); +}; + +const styles = StyleSheet.create({ + badge: { + minHeight: 18, + paddingHorizontal: 6, + borderRadius: 9, + alignItems: 'center', + justifyContent: 'center', + }, + text: { + fontSize: 12, + fontWeight: '600', + }, +}); + +export default Badge; diff --git a/components/BlocksAccordion.tsx b/components/BlocksAccordion.tsx new file mode 100644 index 00000000000..66716625525 --- /dev/null +++ b/components/BlocksAccordion.tsx @@ -0,0 +1,469 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + LayoutChangeEvent, + ListRenderItemInfo, + StyleSheet, + Text, + TextStyle, + TouchableOpacity, + useColorScheme, + useWindowDimensions, + View, +} from 'react-native'; +import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import LottieView from 'lottie-react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import dayjs from 'dayjs'; + +import { useTheme } from './themes'; +import * as BlueElectrum from '../blue_modules/BlueElectrum'; +import loc, { formatBalanceWithoutSuffix } from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; + +const BLOCK_COUNT = 5; +const COLLAPSED_HEIGHT = 0; +const BLOCKS_HEIGHT = 130; +const SUMMARY_HEIGHT_ESTIMATE = 40; +const EXPANDED_HEIGHT_ESTIMATE = SUMMARY_HEIGHT_ESTIMATE + BLOCKS_HEIGHT; +const ANIMATION_DURATION = 280; +const ANIMATION_EASING = Easing.out(Easing.cubic); +const BLOCK_CARD_WIDTH = 120; +const BLOCK_CARD_GAP = 8; +const ITEM_LENGTH = BLOCK_CARD_WIDTH + BLOCK_CARD_GAP; +const HORIZONTAL_PADDING = 8; +const STATE_CARD_MARGIN_H = 24; +const txblockAnimation = require('../img/txblock.json'); +const BLOCK_GRADIENT_EDGE_OPACITY = 0.45; +const BLOCK_GRADIENT_TOP_START = { x: 0.5, y: 0 }; +const BLOCK_GRADIENT_TOP_END = { x: 0.5, y: 1 }; +const BLOCK_GRADIENT_BOTTOM_START = { x: 0.5, y: 1 }; +const BLOCK_GRADIENT_BOTTOM_END = { x: 0.5, y: 0 }; + +const hexToRgba = (hex: string, alpha: number): string => { + const normalized = hex.replace('#', ''); + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +const computeBlockHeights = (txHeight: number, tip: number): number[] => { + const distance = Math.max(0, tip - txHeight); + let startHeight = txHeight - 2; + if (distance < 2) { + startHeight = tip - (BLOCK_COUNT - 1); + } + let heights = Array.from({ length: BLOCK_COUNT }, (_, i) => startHeight + i); + if (!heights.includes(txHeight)) { + const centeredStart = txHeight - Math.floor((BLOCK_COUNT - 1) / 2); + heights = Array.from({ length: BLOCK_COUNT }, (_, i) => centeredStart + i); + } + return heights; +}; + +interface BlocksAccordionProps { + txHash: string; + isSent: boolean; + isExpanded: boolean; + vsize?: number | null; + feeSats?: number | null; + feeRate?: number | null; + onPress?: () => void; +} + +interface BlockData { + height: number; + timestamp?: number; +} + +const getItemLayout = (_: unknown, index: number) => ({ + length: ITEM_LENGTH, + offset: HORIZONTAL_PADDING + index * ITEM_LENGTH, + index, +}); + +/** Bolds substituted placeholder values only — templates must use flat keys with primitive values, not nested phrases. */ +const renderBoldFormattedParts = (template: string, values: Record, boldStyle: TextStyle): React.ReactNode[] => { + const regex = /\{(\w+)\}/g; + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match; + let index = 0; + + while ((match = regex.exec(template)) !== null) { + if (match.index > lastIndex) { + parts.push(template.substring(lastIndex, match.index)); + } + const value = values[match[1]]; + if (value !== undefined) { + parts.push( + + {value} + , + ); + } + lastIndex = regex.lastIndex; + } + if (lastIndex < template.length) { + parts.push(template.substring(lastIndex)); + } + + return parts; +}; + +const BlocksAccordion: React.FC = ({ txHash, isSent, isExpanded, vsize, feeSats, feeRate, onPress }) => { + const { colors } = useTheme(); + const colorScheme = useColorScheme(); + const { width: windowWidth } = useWindowDimensions(); + const [blocks, setBlocks] = useState([]); + const [confirmedHeight, setConfirmedHeight] = useState(null); + const [currentTip, setCurrentTip] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(false); + const [measuredHeight, setMeasuredHeight] = useState(0); + const animatedHeight = useSharedValue(COLLAPSED_HEIGHT); + const fetchStartedRef = useRef(false); + const activeTxHashRef = useRef(txHash); + const blocksListRef = useRef>(null); + + const accentColor = isSent ? colors.transactionSentColor : colors.transactionReceivedColor; + const borderAccent = isSent ? colors.outgoingForegroundColor : colors.incomingForegroundColor; + const blockCardBg = hexToRgba(isSent ? colors.transactionSentColor : colors.transactionReceivedColor, 0.16); + + const lottieColorFilters = useMemo(() => [{ keypath: '**', color: borderAccent }], [borderAccent]); + + const blockGradientColors = useMemo( + () => + colorScheme === 'dark' + ? [`rgba(0, 0, 0, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(0, 0, 0, 0)'] + : [`rgba(255, 255, 255, ${BLOCK_GRADIENT_EDGE_OPACITY})`, 'rgba(255, 255, 255, 0)'], + [colorScheme], + ); + + const stylesHook = StyleSheet.create({ + blockCardBase: { backgroundColor: blockCardBg }, + confirmedBlockCard: { borderColor: borderAccent, borderWidth: 2, backgroundColor: 'transparent' }, + summaryText: { color: accentColor }, + summaryBold: { color: accentColor, fontWeight: '700' }, + }); + + const fetchBlockData = useCallback(async () => { + if (fetchStartedRef.current) return; + fetchStartedRef.current = true; + const fetchTxHash = txHash; + setError(false); + setLoading(true); + try { + const blockInfo = await BlueElectrum.getConfirmedBlockHeight(fetchTxHash); + + if (fetchTxHash !== activeTxHashRef.current) return; + + if (!blockInfo || blockInfo.height <= 0) { + setError(true); + return; + } + + const { height: txHeight, tip } = blockInfo; + const heights = computeBlockHeights(txHeight, tip); + + let timestamps: Record = {}; + try { + timestamps = await BlueElectrum.getBlockTimestamps(heights); + } catch (e) { + console.warn('BlocksAccordion: block timestamps fetch failed', e); + } + + if (fetchTxHash !== activeTxHashRef.current) return; + + setConfirmedHeight(txHeight); + setCurrentTip(tip); + setBlocks(heights.map(h => ({ height: h, timestamp: timestamps[h] }))); + } catch (e) { + console.warn('BlocksAccordion: failed to fetch block data', e); + if (fetchTxHash === activeTxHashRef.current) { + setError(true); + } + } finally { + // Prevent stale requests from mutating UI/loading for a newer txHash. + if (fetchTxHash === activeTxHashRef.current) { + fetchStartedRef.current = false; + setLoading(false); + } + } + }, [txHash]); + + const handleRetry = useCallback(() => { + setError(false); + fetchBlockData(); + }, [fetchBlockData]); + + useEffect(() => { + activeTxHashRef.current = txHash; + fetchStartedRef.current = false; + // Allow the new `txHash` request to start even if the previous request is still in-flight. + // The stale request's guarded `finally` must not clear `loading`, but the new request needs a clean start. + setLoading(false); + setBlocks([]); + setConfirmedHeight(null); + setCurrentTip(null); + setError(false); + setMeasuredHeight(0); + }, [txHash]); + + useEffect(() => { + if (!isExpanded) { + setError(false); + return; + } + if (loading || fetchStartedRef.current) return; + if (confirmedHeight !== null && blocks.length > 0) return; + if (error) return; + fetchBlockData(); + }, [isExpanded, loading, confirmedHeight, blocks.length, error, fetchBlockData]); + + const onContentLayout = useCallback( + (e: LayoutChangeEvent) => { + if (!isExpanded) return; + const height = Math.ceil(e.nativeEvent.layout.height); + if (height > 0) { + setMeasuredHeight(height); + } + }, + [isExpanded], + ); + + useEffect(() => { + const fallbackHeight = loading || error ? BLOCKS_HEIGHT : EXPANDED_HEIGHT_ESTIMATE; + const target = isExpanded ? (measuredHeight > 0 ? measuredHeight : fallbackHeight) : COLLAPSED_HEIGHT; + animatedHeight.value = withTiming(target, { duration: ANIMATION_DURATION, easing: ANIMATION_EASING }); + }, [isExpanded, measuredHeight, animatedHeight, loading, error]); + + const confirmedIndex = useMemo(() => { + if (confirmedHeight === null) return 0; + return blocks.findIndex(b => b.height === confirmedHeight); + }, [blocks, confirmedHeight]); + + const containerWidth = windowWidth - STATE_CARD_MARGIN_H * 2; + const scrollOffset = useMemo(() => { + if (confirmedIndex <= 0) return 0; + const itemCenter = HORIZONTAL_PADDING + confirmedIndex * ITEM_LENGTH + BLOCK_CARD_WIDTH / 2; + return Math.max(0, itemCenter - containerWidth / 2); + }, [confirmedIndex, containerWidth]); + + useEffect(() => { + if (!isExpanded || blocks.length === 0 || scrollOffset === 0) return; + const frame = requestAnimationFrame(() => { + blocksListRef.current?.scrollToOffset({ offset: scrollOffset, animated: false }); + }); + return () => cancelAnimationFrame(frame); + }, [isExpanded, blocks.length, scrollOffset]); + + const animatedContentStyle = useAnimatedStyle(() => ({ + height: animatedHeight.value, + overflow: 'hidden' as const, + })); + + const summaryContent = useMemo(() => { + if (confirmedHeight === null || currentTip === null) return null; + + const rawBehind = currentTip - confirmedHeight; + const isLatestBlock = rawBehind === 0; + const blockHeight = String(confirmedHeight); + const confirmationParts = renderBoldFormattedParts( + isLatestBlock ? loc.transactions.blocks_confirmed_latest : loc.transactions.blocks_confirmed_summary, + { blockHeight }, + stylesHook.summaryBold, + ); + + const hasFeeDetails = vsize != null && feeRate != null && feeSats != null; + if (!hasFeeDetails) { + return {confirmationParts}; + } + + const feeDisplay = `${formatBalanceWithoutSuffix(feeSats, BitcoinUnit.SATS, true)} sats`; + const feeParts = renderBoldFormattedParts( + loc.transactions.blocks_confirmed_fee_summary, + { + vsize: `${vsize} vb`, + feeRate: `${Number(feeRate.toFixed(1))} sats/vb`, + fee: feeDisplay, + }, + stylesHook.summaryBold, + ); + + return ( + + {confirmationParts} {feeParts} + + ); + }, [confirmedHeight, currentTip, feeRate, feeSats, stylesHook.summaryBold, stylesHook.summaryText, vsize]); + + const keyExtractor = useCallback((item: BlockData) => String(item.height), []); + + const renderBlock = useCallback( + ({ item }: ListRenderItemInfo) => { + const isConfirmed = confirmedHeight !== null && item.height === confirmedHeight; + return ( + + {isConfirmed && ( + <> + + + + + )} + {item.height} + + + {item.timestamp ? dayjs(item.timestamp * 1000).format('LT') : '-'} + + + + ); + }, + [ + blockGradientColors, + confirmedHeight, + lottieColorFilters, + stylesHook.blockCardBase, + stylesHook.confirmedBlockCard, + stylesHook.summaryText, + ], + ); + + let bodyContent: React.ReactNode; + if (loading) { + bodyContent = ( + + + + ); + } else if (error) { + bodyContent = ( + + {loc.transactions.blocks_load_error} + + ); + } else { + bodyContent = ( + <> + {summaryContent && ( + + {summaryContent} + + )} + + + ); + } + + return ( + + {bodyContent} + + ); +}; + +const styles = StyleSheet.create({ + loadingContainer: { + justifyContent: 'center', + alignItems: 'center', + minHeight: BLOCKS_HEIGHT, + }, + errorContainer: { + justifyContent: 'center', + alignItems: 'center', + minHeight: BLOCKS_HEIGHT, + paddingHorizontal: 16, + }, + errorText: { + fontSize: 13, + lineHeight: 16, + fontWeight: '500', + letterSpacing: -0.5, + textAlign: 'center', + }, + summaryContainer: { + paddingHorizontal: 16, + paddingTop: 0, + paddingBottom: 8, + }, + summaryText: { + fontSize: 13, + lineHeight: 16, + fontWeight: '500', + letterSpacing: -0.5, + }, + blocksList: { + height: BLOCKS_HEIGHT, + }, + scrollContent: { + paddingHorizontal: HORIZONTAL_PADDING, + paddingVertical: 8, + }, + blockCard: { + width: BLOCK_CARD_WIDTH, + height: 110, + borderRadius: 12, + padding: 12, + justifyContent: 'space-between', + borderWidth: 1, + borderColor: 'transparent', + marginRight: BLOCK_CARD_GAP, + overflow: 'hidden', + }, + blockLottie: { + ...StyleSheet.absoluteFill, + }, + blockGradient: { + ...StyleSheet.absoluteFill, + }, + blockHeight: { + fontSize: 15, + fontWeight: '700', + }, + blockDateContainer: { + marginTop: 'auto', + }, + blockDate: { + fontSize: 13, + fontWeight: '500', + }, +}); + +export default BlocksAccordion; diff --git a/components/BlueBigCheckmark.tsx b/components/BlueBigCheckmark.tsx new file mode 100644 index 00000000000..80d2d1bade9 --- /dev/null +++ b/components/BlueBigCheckmark.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { StyleSheet, View, ViewProps } from 'react-native'; +import Icon from './Icon'; +import { useTheme } from './themes'; + +interface BlueBigCheckmarkProps extends ViewProps {} + +export function BlueBigCheckmark(props: BlueBigCheckmarkProps) { + const { colors } = useTheme(); + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#ccddf9', + width: 120, + height: 120, + borderRadius: 60, + alignSelf: 'center', + justifyContent: 'center', + marginTop: 0, + marginBottom: 0, + }, +}); diff --git a/components/BlueButtonLink.tsx b/components/BlueButtonLink.tsx new file mode 100644 index 00000000000..351dfffba80 --- /dev/null +++ b/components/BlueButtonLink.tsx @@ -0,0 +1,34 @@ +import React, { forwardRef } from 'react'; +import { Pressable, PressableProps, StyleSheet, Text } from 'react-native'; + +import { useTheme } from './themes'; + +interface BlueButtonLinkProps extends PressableProps { + title: string; +} + +const BlueButtonLink = forwardRef, BlueButtonLinkProps>((props, ref) => { + const { colors } = useTheme(); + return ( + [styles.blueButtonLink, pressed && styles.pressed]} {...props} ref={ref}> + {props.title} + + ); +}); + +const styles = StyleSheet.create({ + blueButtonLink: { + minWidth: 100, + minHeight: 36, + justifyContent: 'center', + }, + blueButtonLinkText: { + textAlign: 'center', + fontSize: 16, + }, + pressed: { + opacity: 0.6, + }, +}); + +export default BlueButtonLink; diff --git a/components/BlueCard.tsx b/components/BlueCard.tsx new file mode 100644 index 00000000000..c7a9cccffce --- /dev/null +++ b/components/BlueCard.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { StyleSheet, View, ViewProps } from 'react-native'; + +const BlueCard: React.FC = props => { + return ; +}; + +const styles = StyleSheet.create({ + blueCard: { + padding: 20, + }, +}); + +export default BlueCard; diff --git a/components/BlueFormLabel.tsx b/components/BlueFormLabel.tsx new file mode 100644 index 00000000000..42928a0af8b --- /dev/null +++ b/components/BlueFormLabel.tsx @@ -0,0 +1,21 @@ +import { useLocale } from '@react-navigation/native'; +import React from 'react'; +import { StyleSheet, Text, TextProps } from 'react-native'; + +import { useTheme } from './themes'; + +const BlueFormLabel: React.FC = props => { + const { colors } = useTheme(); + const { direction } = useLocale(); + + return ; +}; + +const styles = StyleSheet.create({ + blueFormLabel: { + fontWeight: '400', + marginHorizontal: 20, + }, +}); + +export default BlueFormLabel; diff --git a/components/BlueFormMultiInput.tsx b/components/BlueFormMultiInput.tsx new file mode 100644 index 00000000000..4564286bf54 --- /dev/null +++ b/components/BlueFormMultiInput.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Platform, StyleSheet, TextInput, TextInputProps } from 'react-native'; + +import { useTheme } from './themes'; + +const BlueFormMultiInput: React.FC = props => { + const { colors } = useTheme(); + const { style, editable, ...restProps } = props; + + return ( + + ); +}; + +const styles = StyleSheet.create({ + blueFormMultiInput: { + paddingHorizontal: 8, + paddingVertical: 16, + flex: 1, + marginTop: 5, + marginHorizontal: 20, + borderWidth: 1, + borderBottomWidth: 0.5, + borderRadius: 4, + textAlignVertical: 'top', + }, +}); + +export default BlueFormMultiInput; diff --git a/components/BlueLoading.tsx b/components/BlueLoading.tsx new file mode 100644 index 00000000000..3545cd7b40a --- /dev/null +++ b/components/BlueLoading.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ActivityIndicator, View, ViewProps, ActivityIndicatorProps, StyleSheet } from 'react-native'; +import { useTheme } from './themes'; + +interface BlueLoadingProps extends ViewProps, Pick {} + +export const BlueLoading: React.FC = props => { + const { color, size, ...otherProps } = props; + const { colors } = useTheme(); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, justifyContent: 'center' }, +}); diff --git a/components/BlueSpacing.tsx b/components/BlueSpacing.tsx new file mode 100644 index 00000000000..4789a6e78b1 --- /dev/null +++ b/components/BlueSpacing.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { View, ViewProps, StyleSheet } from 'react-native'; + +interface BlueSpacingProps extends ViewProps { + horizontal?: boolean; // Optional prop to determine if spacing is horizontal +} + +export const BlueSpacing10: React.FC = props => { + const { style, ...otherProps } = props; + return ; +}; + +export const BlueSpacing20: React.FC = props => { + const { horizontal = false, style, ...otherProps } = props; + return ; +}; + +export const BlueSpacing40: React.FC = props => { + const { style, ...otherProps } = props; + return ; +}; + +export const BlueSpacing: React.FC = props => { + const { style, ...otherProps } = props; + return ; +}; + +const styles = StyleSheet.create({ + spacing10: { + height: 10, + }, + spacing20Vertical: { + height: 20, + width: 0, + opacity: 0, + }, + spacing20Horizontal: { + height: 0, + width: 20, + opacity: 0, + }, + spacing40: { + height: 40, + }, + spacing60: { + height: 60, + }, +}); diff --git a/components/BlueText.tsx b/components/BlueText.tsx new file mode 100644 index 00000000000..1c5645fc6ba --- /dev/null +++ b/components/BlueText.tsx @@ -0,0 +1,62 @@ +import { useLocale } from '@react-navigation/native'; +import React from 'react'; +import { StyleSheet, Text, TextProps } from 'react-native'; + +import { useTheme } from './themes'; + +interface BlueTextProps extends TextProps { + bold?: boolean; + h1?: boolean; + h2?: boolean; + h3?: boolean; + h4?: boolean; +} + +const BlueText: React.FC = ({ bold = false, h1, h2, h3, h4, style: passedStyle, ...props }) => { + const { colors } = useTheme(); + const { direction } = useLocale(); + + let headingStyle = {}; + if (h1) { + headingStyle = styles.h1; + } else if (h2) { + headingStyle = styles.h2; + } else if (h3) { + headingStyle = styles.h3; + } else if (h4) { + headingStyle = styles.h4; + } + + const hasHeading = h1 || h2 || h3 || h4; + const style = StyleSheet.compose( + { + color: colors.foregroundColor, + writingDirection: direction, + fontWeight: hasHeading ? undefined : bold ? 'bold' : 'normal', + ...headingStyle, + }, + passedStyle, + ); + return ; +}; + +const styles = StyleSheet.create({ + h1: { + fontSize: 40, + fontWeight: 'bold', + }, + h2: { + fontSize: 34, + fontWeight: 'bold', + }, + h3: { + fontSize: 28, + fontWeight: 'bold', + }, + h4: { + fontSize: 22, + fontWeight: 'bold', + }, +}); + +export default BlueText; diff --git a/components/BlueTextCentered.tsx b/components/BlueTextCentered.tsx new file mode 100644 index 00000000000..d20b922cc55 --- /dev/null +++ b/components/BlueTextCentered.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { StyleSheet, Text, TextProps } from 'react-native'; + +import { useTheme } from './themes'; + +const BlueTextCentered: React.FC = props => { + const { colors } = useTheme(); + return ; +}; + +const styles = StyleSheet.create({ + blueTextCentered: { + textAlign: 'center', + }, +}); + +export default BlueTextCentered; diff --git a/components/BlurredBalanceView.tsx b/components/BlurredBalanceView.tsx new file mode 100644 index 00000000000..67ca8d6d84b --- /dev/null +++ b/components/BlurredBalanceView.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Icon from './Icon'; + +export const BlurredBalanceView = () => { + return ( + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 9, + }, + background: { + backgroundColor: 'rgba(255, 255, 255, 0.5)', + height: 30, + width: 110, + marginRight: 8, + borderRadius: 9, + }, +}); diff --git a/components/BottomModal.js b/components/BottomModal.js deleted file mode 100644 index 40a42979e6c..00000000000 --- a/components/BottomModal.js +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { StyleSheet, Platform, useWindowDimensions, View } from 'react-native'; -import Modal from 'react-native-modal'; -import { BlueButton, BlueSpacing10 } from '../BlueComponents'; -import loc from '../loc'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - root: { - justifyContent: 'flex-end', - margin: 0, - }, - hasDoneButton: { - padding: 16, - paddingBottom: 24, - }, -}); - -const BottomModal = ({ - onBackButtonPress = undefined, - onBackdropPress = undefined, - onClose, - windowHeight = undefined, - windowWidth = undefined, - doneButton = undefined, - avoidKeyboard = false, - allowBackdropPress = true, - ...props -}) => { - const valueWindowHeight = useWindowDimensions().height; - const valueWindowWidth = useWindowDimensions().width; - const handleBackButtonPress = onBackButtonPress ?? onClose; - const handleBackdropPress = allowBackdropPress ? onBackdropPress ?? onClose : undefined; - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - hasDoneButton: { - backgroundColor: colors.elevated, - }, - }); - return ( - - {props.children} - {doneButton && ( - - - - - )} - - ); -}; - -BottomModal.propTypes = { - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.element), PropTypes.element]), - onBackButtonPress: PropTypes.func, - onBackdropPress: PropTypes.func, - onClose: PropTypes.func, - doneButton: PropTypes.bool, - windowHeight: PropTypes.number, - windowWidth: PropTypes.number, - avoidKeyboard: PropTypes.bool, - allowBackdropPress: PropTypes.bool, -}; - -export default BottomModal; diff --git a/components/Button.js b/components/Button.js deleted file mode 100644 index ac2c1a82ae5..00000000000 --- a/components/Button.js +++ /dev/null @@ -1,85 +0,0 @@ -import React from 'react'; -import { TouchableOpacity, View, Text, StyleSheet } from 'react-native'; -import PropTypes from 'prop-types'; -import { useTheme } from '@react-navigation/native'; - -export const ButtonStyle = { default: 'default', destroy: 'destroy', grey: 'grey' }; -const Button = props => { - const { onPress, text = '', disabled = false, buttonStyle = ButtonStyle.default } = props; - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - buttonGrey: { - backgroundColor: colors.lightButton, - }, - textGray: { - color: colors.buttonTextColor, - }, - }); - const textStyles = () => { - if (buttonStyle === ButtonStyle.grey) { - return stylesHook.textGray; - } else if (buttonStyle === ButtonStyle.destroy) { - return styles.textDestroy; - } else { - return styles.textDefault; - } - }; - - const buttonStyles = () => { - if (buttonStyle === ButtonStyle.grey) { - return stylesHook.buttonGrey; - } else if (buttonStyle === ButtonStyle.destroy) { - return styles.buttonDestroy; - } else { - return styles.buttonDefault; - } - }; - - const opacity = { opacity: disabled ? 0.5 : 1.0 }; - - return ( - - - {text} - - - ); -}; - -const styles = StyleSheet.create({ - buttonContainer: { - borderRadius: 9, - minHeight: 49, - paddingHorizontal: 8, - justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', - alignSelf: 'auto', - flexGrow: 1, - marginHorizontal: 4, - }, - buttonDefault: { - backgroundColor: '#EBF2FB', - }, - buttonDestroy: { - backgroundColor: '#FFF5F5', - }, - text: { - fontWeight: '600', - fontSize: 15, - }, - textDefault: { - color: '#1961B9', - }, - textDestroy: { - color: '#D0021B', - }, -}); - -export default Button; -Button.propTypes = { - onPress: PropTypes.func.isRequired, - text: PropTypes.string.isRequired, - disabled: PropTypes.bool, - buttonStyle: PropTypes.string, -}; diff --git a/components/Button.tsx b/components/Button.tsx new file mode 100644 index 00000000000..54c6c88ca48 --- /dev/null +++ b/components/Button.tsx @@ -0,0 +1,100 @@ +import React, { forwardRef } from 'react'; +import { ActivityIndicator, StyleProp, StyleSheet, Text, Pressable, PressableProps, View, ViewStyle, Platform } from 'react-native'; +import Icon, { type IconProps } from './Icon'; + +import { useTheme } from './themes'; + +interface ButtonProps extends PressableProps { + backgroundColor?: string; + buttonTextColor?: string; + disabled?: boolean; + testID?: string; + icon?: Pick & { color: string }; + title?: string; + style?: StyleProp; + onPress?: () => void; + showActivityIndicator?: boolean; +} + +export const Button = forwardRef, ButtonProps>((props, ref) => { + const { colors } = useTheme(); + + let backgroundColor = props.backgroundColor ?? colors.mainColor; + let fontColor = props.buttonTextColor ?? colors.buttonTextColor; + if (props.disabled) { + backgroundColor = colors.buttonDisabledBackgroundColor; + fontColor = colors.buttonDisabledTextColor; + } + + const buttonStyle = { + ...styles.button, + backgroundColor, + borderColor: props.disabled ? colors.buttonDisabledBackgroundColor : 'transparent', + }; + + const textStyle = { + ...styles.text, + color: fontColor, + }; + + const buttonView = props.showActivityIndicator ? ( + + ) : ( + <> + {props.icon && } + {props.title && {props.title}} + + ); + + return props.onPress ? ( + + [Platform.OS === 'ios' && pressed ? styles.pressed : null, buttonStyle, props.style, styles.content]} + accessibilityRole="button" + onPress={props.onPress} + disabled={props.disabled} + {...props} + > + {buttonView} + + + ) : ( + {buttonView} + ); +}); + +const styles = StyleSheet.create({ + button: { + borderWidth: 0.7, + minHeight: 45, + height: 48, + maxHeight: 48, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 16, + flexGrow: 1, + }, + content: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + text: { + marginHorizontal: 8, + fontSize: 16, + fontWeight: '600', + }, + pressableWrapper: { + overflow: 'hidden', + borderRadius: 25, + }, + pressed: { + opacity: 0.6, + }, +}); + +export default Button; diff --git a/components/CameraScreen.tsx b/components/CameraScreen.tsx new file mode 100644 index 00000000000..48338643fe2 --- /dev/null +++ b/components/CameraScreen.tsx @@ -0,0 +1,266 @@ +import React, { useRef, useState } from 'react'; +import { Animated, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { Camera, CameraApi, CameraType, Orientation } from 'react-native-camera-kit-no-google'; +import { OnOrientationChangeData, OnReadCodeData } from 'react-native-camera-kit-no-google/dist/CameraProps'; + +import { isDesktop } from '../blue_modules/environment'; +import { triggerSelectionHapticFeedback } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; +import Icon from './Icon'; + +interface CameraScreenProps { + onCancelButtonPress: () => void; + showImagePickerButton?: boolean; + showFilePickerButton?: boolean; + onImagePickerButtonPress?: () => void; + onFilePickerButtonPress?: () => void; + onReadCode?: (event: OnReadCodeData) => void; +} + +const CameraScreen: React.FC = ({ + onCancelButtonPress, + showImagePickerButton, + showFilePickerButton, + onImagePickerButtonPress, + onFilePickerButtonPress, + onReadCode, +}) => { + const cameraRef = useRef(null); + const [torchMode, setTorchMode] = useState(false); + const [cameraType, setCameraType] = useState(CameraType.Back); + const [zoom, setZoom] = useState(); + const [orientationAnim] = useState(new Animated.Value(3)); + + const onSwitchCameraPressed = () => { + const direction = cameraType === CameraType.Back ? CameraType.Front : CameraType.Back; + setCameraType(direction); + setZoom(1); // When changing camera type, reset to default zoom for that camera + triggerSelectionHapticFeedback(); + }; + + const onSetTorch = () => { + setTorchMode(!torchMode); + triggerSelectionHapticFeedback(); + }; + + // Counter-rotate the icons to indicate the actual orientation of the captured photo. + // For this example, it'll behave incorrectly since UI orientation is allowed (and already-counter rotates the entire screen) + // For real phone apps, lock your UI orientation using a library like 'react-native-orientation-locker' + const rotateUi = true; + const uiRotation = orientationAnim.interpolate({ + inputRange: [1, 2, 3, 4], + outputRange: ['180deg', '90deg', '0deg', '-90deg'], + }); + const uiRotationStyle = rotateUi ? { transform: [{ rotate: uiRotation }] } : {}; + + function rotateUiTo(rotationValue: number) { + Animated.timing(orientationAnim, { + toValue: rotationValue, + useNativeDriver: true, + duration: 200, + isInteraction: false, + }).start(); + } + + const handleZoom = (e: { nativeEvent: { zoom: number } }) => { + console.debug('zoom', e.nativeEvent.zoom); + setZoom(e.nativeEvent.zoom); + }; + + const handleOrientationChange = (e: OnOrientationChangeData) => { + switch (e.nativeEvent.orientation) { + case Orientation.PORTRAIT_UPSIDE_DOWN: + console.debug('orientationChange', 'PORTRAIT_UPSIDE_DOWN'); + rotateUiTo(1); + break; + case Orientation.LANDSCAPE_LEFT: + console.debug('orientationChange', 'LANDSCAPE_LEFT'); + rotateUiTo(2); + break; + case Orientation.PORTRAIT: + console.debug('orientationChange', 'PORTRAIT'); + rotateUiTo(3); + break; + case Orientation.LANDSCAPE_RIGHT: + console.debug('orientationChange', 'LANDSCAPE_RIGHT'); + rotateUiTo(4); + break; + default: + console.debug('orientationChange', e.nativeEvent); + break; + } + }; + + const handleReadCode = (event: OnReadCodeData) => { + onReadCode?.(event); + }; + + return ( + + {/* Render top buttons only if not desktop as they would not be relevant */} + {!isDesktop && ( + + + + + + + + {showImagePickerButton && ( + + + + + + )} + {showFilePickerButton && ( + + + + + + )} + + + )} + + + + + + {loc._.cancel} + + {isDesktop ? ( + + {showImagePickerButton && ( + + + + + + )} + {showFilePickerButton && ( + + + + + + )} + + ) : ( + + + + + + )} + + + ); +}; + +export default CameraScreen; + +const styles = StyleSheet.create({ + activeTorch: { + backgroundColor: '#fff', + }, + screen: { + height: '100%', + backgroundColor: '#000000', + }, + topButtons: { + padding: 10, + zIndex: 10, + flexDirection: 'row', + justifyContent: 'space-between', + }, + topButton: { + backgroundColor: '#222', + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + }, + topButtonImg: { + margin: 10, + width: 24, + height: 24, + }, + cameraContainer: { + justifyContent: 'center', + flex: 1, + }, + cameraPreview: { + width: '100%', + height: '100%', + }, + bottomButtons: { + padding: 10, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + backTextStyle: { + padding: 10, + color: 'white', + fontSize: 20, + }, + rightButtonsContainer: { + flexDirection: 'row', + alignItems: 'center', + }, + bottomButton: { + backgroundColor: '#222', + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + marginLeft: 10, + }, + spacing: { + marginLeft: 20, + }, +}); diff --git a/components/CoinsSelected.js b/components/CoinsSelected.js deleted file mode 100644 index 2c7c12550a1..00000000000 --- a/components/CoinsSelected.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { Avatar } from 'react-native-elements'; - -import loc from '../loc'; - -const styles = StyleSheet.create({ - root: { - height: 48, - borderRadius: 8, - backgroundColor: '#3477F6', - flexDirection: 'row', - }, - labelContainer: { - flex: 1, - justifyContent: 'center', - paddingLeft: 16, - }, - labelText: { - color: 'white', - fontWeight: 'bold', - }, - buttonContainer: { - width: 48, - alignItems: 'center', - justifyContent: 'center', - }, - ball: { - width: 26, - height: 26, - borderRadius: 13, - backgroundColor: 'rgba(255, 255, 255, 0.32)', - }, -}); - -const CoinsSelected = ({ number, onContainerPress, onClose }) => ( - - - {loc.formatString(loc.cc.coins_selected, { number })} - - - - - -); - -CoinsSelected.propTypes = { - number: PropTypes.number.isRequired, - onContainerPress: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - -export default CoinsSelected; diff --git a/components/CoinsSelected.tsx b/components/CoinsSelected.tsx new file mode 100644 index 00000000000..5575acdaa9e --- /dev/null +++ b/components/CoinsSelected.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import Avatar from './Avatar'; + +import loc from '../loc'; + +const styles = StyleSheet.create({ + root: { + height: 48, + borderRadius: 8, + backgroundColor: '#3477F6', + flexDirection: 'row', + }, + labelContainer: { + flex: 1, + justifyContent: 'center', + paddingLeft: 16, + }, + labelText: { + color: 'white', + fontWeight: 'bold', + }, + buttonContainer: { + width: 48, + alignItems: 'center', + justifyContent: 'center', + }, + ball: { + width: 26, + height: 26, + borderRadius: 13, + backgroundColor: 'rgba(255, 255, 255, 0.32)', + }, +}); + +interface CoinsSelectedProps { + number: number; + onContainerPress: () => void; + onClose: () => void; +} + +const CoinsSelected: React.FC = ({ number, onContainerPress, onClose }) => ( + + + {loc.formatString(loc.cc.coins_selected, { number })} + + + + + +); + +export default CoinsSelected; diff --git a/components/Context/SettingsProvider.tsx b/components/Context/SettingsProvider.tsx new file mode 100644 index 00000000000..f7f62e0d5a7 --- /dev/null +++ b/components/Context/SettingsProvider.tsx @@ -0,0 +1,402 @@ +import React, { createContext, useCallback, useEffect, useMemo, useState } from 'react'; +import DefaultPreference from 'react-native-default-preference'; +import { isReadClipboardAllowed, setReadClipboardAllowed } from '../../blue_modules/clipboard'; +import { getPreferredCurrency, GROUP_IO_BLUEWALLET, initCurrencyDaemon, setPreferredCurrency } from '../../blue_modules/currency'; +import { clearUseURv1, isURv1Enabled, setUseURv1 } from '../../blue_modules/ur'; +import { BlueApp } from '../../class/blue-app'; +import { saveLanguage, STORAGE_KEY } from '../../loc'; +import { FiatUnit, TFiatUnit } from '../../models/fiatUnit'; +import { + getEnabled as getIsDeviceQuickActionsEnabled, + setEnabled as setIsDeviceQuickActionsEnabled, +} from '../../hooks/useDeviceQuickActions'; +import { getIsHandOffUseEnabled, setIsHandOffUseEnabled } from '../HandOffComponent'; +import { useStorage } from '../../hooks/context/useStorage'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { TotalWalletsBalanceKey, TotalWalletsBalancePreferredUnit } from '../TotalWalletsBalance'; +import { BLOCK_EXPLORERS, getBlockExplorerUrl, saveBlockExplorer, BlockExplorer, normalizeUrl } from '../../models/blockExplorer'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import { isBalanceDisplayAllowed, setBalanceDisplayAllowed } from '../../hooks/useWidgetCommunication'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const getDoNotTrackStorage = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const doNotTrack = await DefaultPreference.get(BlueApp.DO_NOT_TRACK); + return doNotTrack === '1'; + } catch { + console.error('Error getting DoNotTrack'); + return false; + } +}; + +export const setTotalBalanceViewEnabledStorage = async (value: boolean): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(TotalWalletsBalanceKey, value ? 'true' : 'false'); + console.debug('setTotalBalanceViewEnabledStorage value:', value); + } catch (e) { + console.error('Error setting TotalBalanceViewEnabled:', e); + } +}; + +export const getIsTotalBalanceViewEnabled = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const isEnabledValue = (await DefaultPreference.get(TotalWalletsBalanceKey)) ?? 'true'; + console.debug('getIsTotalBalanceViewEnabled', isEnabledValue); + return isEnabledValue === 'true'; + } catch (e) { + console.error('Error getting TotalBalanceViewEnabled:', e); + return true; + } +}; + +export const setTotalBalancePreferredUnitStorageFunc = async (unit: BitcoinUnit): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(TotalWalletsBalancePreferredUnit, unit); + } catch (e) { + console.error('Error setting TotalBalancePreferredUnit:', e); + } +}; + +export const getTotalBalancePreferredUnit = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const unit = (await DefaultPreference.get(TotalWalletsBalancePreferredUnit)) as BitcoinUnit | null; + return unit ?? BitcoinUnit.BTC; + } catch (e) { + console.error('Error getting TotalBalancePreferredUnit:', e); + return BitcoinUnit.BTC; + } +}; + +interface SettingsContextType { + preferredFiatCurrency: TFiatUnit; + setPreferredFiatCurrencyStorage: (currency: TFiatUnit) => Promise; + language: string; + setLanguageStorage: (language: string) => Promise; + isHandOffUseEnabled: boolean; + setIsHandOffUseEnabledAsyncStorage: (value: boolean) => Promise; + isPrivacyBlurEnabled: boolean; + setIsPrivacyBlurEnabled: (value: boolean) => void; + isDoNotTrackEnabled: boolean; + setDoNotTrackStorage: (value: boolean) => Promise; + isWidgetBalanceDisplayAllowed: boolean; + setIsWidgetBalanceDisplayAllowedStorage: (value: boolean) => Promise; + isLegacyURv1Enabled: boolean; + setIsLegacyURv1EnabledStorage: (value: boolean) => Promise; + isClipboardGetContentEnabled: boolean; + setIsClipboardGetContentEnabledStorage: (value: boolean) => Promise; + isQuickActionsEnabled: boolean; + setIsQuickActionsEnabledStorage: (value: boolean) => Promise; + isTotalBalanceEnabled: boolean; + setIsTotalBalanceEnabledStorage: (value: boolean) => Promise; + totalBalancePreferredUnit: BitcoinUnit; + setTotalBalancePreferredUnitStorage: (unit: BitcoinUnit) => Promise; + selectedBlockExplorer: BlockExplorer; + setBlockExplorerStorage: (explorer: BlockExplorer) => Promise; + isElectrumDisabled: boolean; + setIsElectrumDisabled: (value: boolean) => void; +} + +const defaultSettingsContext: SettingsContextType = { + preferredFiatCurrency: FiatUnit.USD, + setPreferredFiatCurrencyStorage: async () => {}, + language: 'en', + setLanguageStorage: async () => {}, + isHandOffUseEnabled: false, + setIsHandOffUseEnabledAsyncStorage: async () => {}, + isPrivacyBlurEnabled: true, + setIsPrivacyBlurEnabled: () => {}, + isDoNotTrackEnabled: false, + setDoNotTrackStorage: async () => {}, + isWidgetBalanceDisplayAllowed: true, + setIsWidgetBalanceDisplayAllowedStorage: async () => {}, + isLegacyURv1Enabled: false, + setIsLegacyURv1EnabledStorage: async () => {}, + isClipboardGetContentEnabled: true, + setIsClipboardGetContentEnabledStorage: async () => {}, + isQuickActionsEnabled: true, + setIsQuickActionsEnabledStorage: async () => {}, + isTotalBalanceEnabled: true, + setIsTotalBalanceEnabledStorage: async () => {}, + totalBalancePreferredUnit: BitcoinUnit.BTC, + setTotalBalancePreferredUnitStorage: async () => {}, + selectedBlockExplorer: BLOCK_EXPLORERS.default, + setBlockExplorerStorage: async () => false, + isElectrumDisabled: false, + setIsElectrumDisabled: () => {}, +}; + +export const SettingsContext = createContext(defaultSettingsContext); + +export const SettingsProvider: React.FC<{ children: React.ReactNode }> = React.memo(({ children }: { children: React.ReactNode }) => { + const [preferredFiatCurrency, setPreferredFiatCurrencyState] = useState(FiatUnit.USD); + const [language, setLanguage] = useState('en'); + const [isHandOffUseEnabled, setIsHandOffUseEnabledState] = useState(false); + const [isPrivacyBlurEnabled, setIsPrivacyBlurEnabled] = useState(true); + const [isDoNotTrackEnabled, setIsDoNotTrackEnabled] = useState(false); + const [isWidgetBalanceDisplayAllowed, setIsWidgetBalanceDisplayAllowed] = useState(true); + const [isLegacyURv1Enabled, setIsLegacyURv1Enabled] = useState(false); + const [isClipboardGetContentEnabled, setIsClipboardGetContentEnabled] = useState(true); + const [isQuickActionsEnabled, setIsQuickActionsEnabled] = useState(true); + const [isTotalBalanceEnabled, setIsTotalBalanceEnabled] = useState(true); + const [totalBalancePreferredUnit, setTotalBalancePreferredUnit] = useState(BitcoinUnit.BTC); + const [selectedBlockExplorer, setSelectedBlockExplorer] = useState(BLOCK_EXPLORERS.default); + const [isElectrumDisabled, setIsElectrumDisabled] = useState(true); + + const { walletsInitialized } = useStorage(); + + useEffect(() => { + const loadSettings = async () => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + } catch (e) { + console.error('Error setting preference name:', e); + } + + const promises: Promise[] = [ + BlueElectrum.isDisabled().then(disabled => { + setIsElectrumDisabled(disabled); + }), + getIsHandOffUseEnabled().then(handOff => { + setIsHandOffUseEnabledState(handOff); + }), + AsyncStorage.getItem(STORAGE_KEY).then(lang => { + setLanguage(lang ?? 'en'); + }), + isBalanceDisplayAllowed().then(balanceDisplayAllowed => { + setIsWidgetBalanceDisplayAllowed(balanceDisplayAllowed); + }), + isURv1Enabled().then(urv1Enabled => { + setIsLegacyURv1Enabled(urv1Enabled); + }), + isReadClipboardAllowed().then(clipboardEnabled => { + setIsClipboardGetContentEnabled(clipboardEnabled); + }), + getIsDeviceQuickActionsEnabled().then(quickActionsEnabled => { + setIsQuickActionsEnabled(quickActionsEnabled); + }), + getDoNotTrackStorage().then(doNotTrack => { + setIsDoNotTrackEnabled(doNotTrack); + }), + getIsTotalBalanceViewEnabled().then(totalBalanceEnabled => { + setIsTotalBalanceEnabled(totalBalanceEnabled); + }), + getTotalBalancePreferredUnit().then(preferredUnit => { + setTotalBalancePreferredUnit(preferredUnit); + }), + getBlockExplorerUrl().then(url => { + const predefinedExplorer = Object.values(BLOCK_EXPLORERS).find(explorer => normalizeUrl(explorer.url) === normalizeUrl(url)); + setSelectedBlockExplorer(predefinedExplorer ?? ({ key: 'custom', name: 'Custom', url } as BlockExplorer)); + }), + ]; + + const results = await Promise.allSettled(promises); + + results.forEach((result, index) => { + if (result.status === 'rejected') { + console.error(`Error loading setting ${index}:`, result.reason); + } + }); + }; + + loadSettings(); + }, []); + + useEffect(() => { + initCurrencyDaemon() + .then(getPreferredCurrency) + .then(currency => { + console.debug('SettingsContext currency:', currency); + setPreferredFiatCurrencyState(currency as TFiatUnit); + }) + .catch(e => { + console.error('Error initializing currency daemon or getting preferred currency:', e); + }); + }, []); + + useEffect(() => { + if (walletsInitialized) { + if (isElectrumDisabled) { + BlueElectrum.forceDisconnect(); + } else { + BlueElectrum.ensureConnected({ showAlertOnFailure: true }); + } + } + }, [isElectrumDisabled, walletsInitialized]); + + const setPreferredFiatCurrencyStorage = useCallback(async (currency: TFiatUnit): Promise => { + try { + await setPreferredCurrency(currency); + setPreferredFiatCurrencyState(currency); + } catch (e) { + console.error('Error setting preferredFiatCurrency:', e); + } + }, []); + + const setLanguageStorage = useCallback(async (newLanguage: string): Promise => { + try { + await saveLanguage(newLanguage); + setLanguage(newLanguage); + } catch (e) { + console.error('Error setting language:', e); + } + }, []); + + const setDoNotTrackStorage = useCallback(async (value: boolean): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + if (value) { + await DefaultPreference.set(BlueApp.DO_NOT_TRACK, '1'); + } else { + await DefaultPreference.clear(BlueApp.DO_NOT_TRACK); + } + setIsDoNotTrackEnabled(value); + } catch (e) { + console.error('Error setting DoNotTrack:', e); + } + }, []); + + const setIsHandOffUseEnabledAsyncStorage = useCallback(async (value: boolean): Promise => { + try { + console.debug('setIsHandOffUseEnabledAsyncStorage', value); + await setIsHandOffUseEnabled(value); + setIsHandOffUseEnabledState(value); + } catch (e) { + console.error('Error setting isHandOffUseEnabled:', e); + } + }, []); + + const setIsWidgetBalanceDisplayAllowedStorage = useCallback(async (value: boolean): Promise => { + try { + await setBalanceDisplayAllowed(value); + setIsWidgetBalanceDisplayAllowed(value); + } catch (e) { + console.error('Error setting isWidgetBalanceDisplayAllowed:', e); + } + }, []); + + const setIsLegacyURv1EnabledStorage = useCallback(async (value: boolean): Promise => { + try { + if (value) { + await setUseURv1(); + } else { + await clearUseURv1(); + } + setIsLegacyURv1Enabled(value); + } catch (e) { + console.error('Error setting isLegacyURv1Enabled:', e); + } + }, []); + + const setIsClipboardGetContentEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setReadClipboardAllowed(value); + setIsClipboardGetContentEnabled(value); + } catch (e) { + console.error('Error setting isClipboardGetContentEnabled:', e); + } + }, []); + + const setIsQuickActionsEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setIsDeviceQuickActionsEnabled(value); + setIsQuickActionsEnabled(value); + } catch (e) { + console.error('Error setting isQuickActionsEnabled:', e); + } + }, []); + const setIsTotalBalanceEnabledStorage = useCallback(async (value: boolean): Promise => { + try { + await setTotalBalanceViewEnabledStorage(value); + setIsTotalBalanceEnabled(value); + } catch (e) { + console.error('Error setting isTotalBalanceEnabled:', e); + } + }, []); + + const setTotalBalancePreferredUnitStorage = useCallback(async (unit: BitcoinUnit): Promise => { + try { + await setTotalBalancePreferredUnitStorageFunc(unit); + setTotalBalancePreferredUnit(unit); + } catch (e) { + console.error('Error setting totalBalancePreferredUnit:', e); + } + }, []); + + const setBlockExplorerStorage = useCallback(async (explorer: BlockExplorer): Promise => { + try { + const success = await saveBlockExplorer(explorer.url); + if (success) { + setSelectedBlockExplorer(explorer); + } + return success; + } catch (e) { + console.error('Error setting BlockExplorer:', e); + return false; + } + }, []); + + const value = useMemo( + () => ({ + preferredFiatCurrency, + setPreferredFiatCurrencyStorage, + language, + setLanguageStorage, + isHandOffUseEnabled, + setIsHandOffUseEnabledAsyncStorage, + isPrivacyBlurEnabled, + setIsPrivacyBlurEnabled, + isDoNotTrackEnabled, + setDoNotTrackStorage, + isWidgetBalanceDisplayAllowed, + setIsWidgetBalanceDisplayAllowedStorage, + isLegacyURv1Enabled, + setIsLegacyURv1EnabledStorage, + isClipboardGetContentEnabled, + setIsClipboardGetContentEnabledStorage, + isQuickActionsEnabled, + setIsQuickActionsEnabledStorage, + isTotalBalanceEnabled, + setIsTotalBalanceEnabledStorage, + totalBalancePreferredUnit, + setTotalBalancePreferredUnitStorage, + selectedBlockExplorer, + setBlockExplorerStorage, + isElectrumDisabled, + setIsElectrumDisabled, + }), + [ + preferredFiatCurrency, + setPreferredFiatCurrencyStorage, + language, + setLanguageStorage, + isHandOffUseEnabled, + setIsHandOffUseEnabledAsyncStorage, + isPrivacyBlurEnabled, + setIsPrivacyBlurEnabled, + isDoNotTrackEnabled, + setDoNotTrackStorage, + isWidgetBalanceDisplayAllowed, + setIsWidgetBalanceDisplayAllowedStorage, + isLegacyURv1Enabled, + setIsLegacyURv1EnabledStorage, + isClipboardGetContentEnabled, + setIsClipboardGetContentEnabledStorage, + isQuickActionsEnabled, + setIsQuickActionsEnabledStorage, + isTotalBalanceEnabled, + setIsTotalBalanceEnabledStorage, + totalBalancePreferredUnit, + setTotalBalancePreferredUnitStorage, + selectedBlockExplorer, + setBlockExplorerStorage, + isElectrumDisabled, + ], + ); + + return {children}; +}); diff --git a/components/Context/SizeClassProvider.tsx b/components/Context/SizeClassProvider.tsx new file mode 100644 index 00000000000..b71b069d812 --- /dev/null +++ b/components/Context/SizeClassProvider.tsx @@ -0,0 +1,140 @@ +import React, { createContext, ReactNode, useEffect, useMemo, useState } from 'react'; +import { Dimensions, Platform, useWindowDimensions } from 'react-native'; +import { isDesktop, isTablet } from '../../blue_modules/environment'; +import useAppState from '../../hooks/useAppState'; + +export enum SizeClass { + Compact, + Regular, + Large, +} + +interface ISizeClassContext { + sizeClass: SizeClass; + horizontalSizeClass: SizeClass; + verticalSizeClass: SizeClass; + orientation: 'portrait' | 'landscape'; +} + +const useSizeClassDetection = () => { + const dimensions = useWindowDimensions(); + const [horizontalSizeClass, setHorizontalSizeClass] = useState(SizeClass.Regular); + const [verticalSizeClass, setVerticalSizeClass] = useState(SizeClass.Regular); + const [orientation, setOrientation] = useState<'portrait' | 'landscape'>(dimensions.width < dimensions.height ? 'portrait' : 'landscape'); + + const determineSize = () => { + const { width, height } = Dimensions.get('window'); + const isLandscape = width > height; + setOrientation(isLandscape ? 'landscape' : 'portrait'); + + if (isDesktop) { + setHorizontalSizeClass(SizeClass.Large); + setVerticalSizeClass(SizeClass.Large); + return; + } + + if (Platform.OS === 'ios' && Platform.isPad) { + setHorizontalSizeClass(SizeClass.Regular); + setVerticalSizeClass(SizeClass.Regular); + return; + } + + if (isTablet) { + setHorizontalSizeClass(SizeClass.Regular); + setVerticalSizeClass(SizeClass.Regular); + return; + } + + const aspectRatio = isLandscape ? width / height : height / width; + const screenArea = width * height; + + if (isLandscape) { + setHorizontalSizeClass(aspectRatio >= 1.6 || screenArea >= 250000 ? SizeClass.Regular : SizeClass.Compact); + setVerticalSizeClass(SizeClass.Compact); + } else { + setHorizontalSizeClass(SizeClass.Compact); + setVerticalSizeClass(SizeClass.Regular); + } + }; + + useEffect(() => { + const handleDimensionChange = () => { + determineSize(); + }; + + const dimensionSubscription = Dimensions.addEventListener('change', handleDimensionChange); + + determineSize(); + + return () => { + dimensionSubscription.remove(); + }; + }, []); + + const { currentAppState } = useAppState(); + useEffect(() => { + if (currentAppState === 'active') { + determineSize(); + } + }, [currentAppState]); + + const sizeClass = useMemo(() => { + if ( + (horizontalSizeClass === SizeClass.Large || verticalSizeClass === SizeClass.Large) && + horizontalSizeClass !== SizeClass.Compact && + verticalSizeClass !== SizeClass.Compact + ) { + return SizeClass.Large; + } + + if (horizontalSizeClass === SizeClass.Compact || verticalSizeClass === SizeClass.Compact) { + return SizeClass.Compact; + } + + return SizeClass.Regular; + }, [horizontalSizeClass, verticalSizeClass]); + + useEffect(() => { + console.debug( + `[SizeClass] Size classes updated:`, + `horizontal=${SizeClass[horizontalSizeClass]}`, + `vertical=${SizeClass[verticalSizeClass]}`, + `overall=${SizeClass[sizeClass]}`, + `orientation=${orientation}`, + ); + }, [horizontalSizeClass, verticalSizeClass, sizeClass, orientation]); + + return { + sizeClass, + horizontalSizeClass, + verticalSizeClass, + orientation, + }; +}; + +type SizeClassProviderProps = { + children: ReactNode; +}; + +export const SizeClassContext = createContext({ + sizeClass: SizeClass.Regular, + horizontalSizeClass: SizeClass.Regular, + verticalSizeClass: SizeClass.Regular, + orientation: 'portrait', +}); + +export const SizeClassProvider: React.FC = ({ children }) => { + const { sizeClass, horizontalSizeClass, verticalSizeClass, orientation } = useSizeClassDetection(); + + const contextValue = useMemo( + () => ({ + sizeClass, + horizontalSizeClass, + verticalSizeClass, + orientation, + }), + [sizeClass, horizontalSizeClass, verticalSizeClass, orientation], + ); + + return {children}; +}; diff --git a/components/Context/StorageProvider.tsx b/components/Context/StorageProvider.tsx new file mode 100644 index 00000000000..1b01012ae4c --- /dev/null +++ b/components/Context/StorageProvider.tsx @@ -0,0 +1,586 @@ +import React, { createContext, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { BlueApp as BlueAppClass, TCounterpartyMetadata, TTXMetadata } from '../../class/blue-app'; +import { LegacyWallet } from '../../class/wallets/legacy-wallet'; +import { LightningArkWallet } from '../../class/wallets/lightning-ark-wallet'; +import { WatchOnlyWallet } from '../../class/wallets/watch-only-wallet'; +import type { TWallet } from '../../class/wallets/types'; +import presentAlert from '../../components/Alert'; +import loc, { formatBalanceWithoutSuffix } from '../../loc'; +import * as BlueElectrum from '../../blue_modules/BlueElectrum'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import { registerArkBackgroundTask, stopArkBackgroundTask } from '../../blue_modules/arkade-background'; +import { startAndDecrypt } from '../../blue_modules/start-and-decrypt'; +import { isNotificationsEnabled, majorTomToGroundControl, unsubscribe } from '../../blue_modules/notifications'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { navigationRef } from '../../NavigationService'; +import { getScanWasBBQR } from '../../helpers/scan-qr.ts'; +import { setWalletIdMustUseBBQR } from '../../blue_modules/ur'; + +const BlueApp = BlueAppClass.getInstance(); + +// hashmap of timestamps we _started_ refetching some wallet +const _lastTimeTriedToRefetchWallet: { [walletID: string]: number } = {}; + +interface StorageContextType { + wallets: TWallet[]; + setWalletsWithNewOrder: (wallets: TWallet[]) => void; + txMetadata: TTXMetadata; + counterpartyMetadata: TCounterpartyMetadata; + saveToDisk: (force?: boolean) => Promise; + selectedWalletID: () => string | undefined; // Change from string|undefined to a function + addWallet: (wallet: TWallet) => void; + deleteWallet: (wallet: TWallet) => void; + currentSharedCosigner: string; + setSharedCosigner: (cosigner: string) => void; + addAndSaveWallet: (wallet: TWallet) => Promise; + fetchAndSaveWalletTransactions: (walletID: string) => Promise; + walletsInitialized: boolean; + setWalletsInitialized: (initialized: boolean) => void; + refreshAllWalletTransactions: (lastSnappedTo?: number, showUpdateStatusIndicator?: boolean) => Promise; + resetWallets: () => void; + walletTransactionUpdateStatus: WalletTransactionsStatus | string; + setWalletTransactionUpdateStatus: (status: WalletTransactionsStatus | string) => void; + getTransactions: typeof BlueApp.getTransactions; + fetchWalletBalances: typeof BlueApp.fetchWalletBalances; + fetchWalletTransactions: typeof BlueApp.fetchWalletTransactions; + getBalance: typeof BlueApp.getBalance; + isStorageEncrypted: typeof BlueApp.storageIsEncrypted; + startAndDecrypt: typeof startAndDecrypt; + encryptStorage: typeof BlueApp.encryptStorage; + sleep: typeof BlueApp.sleep; + createFakeStorage: typeof BlueApp.createFakeStorage; + decryptStorage: typeof BlueApp.decryptStorage; + isPasswordInUse: typeof BlueApp.isPasswordInUse; + cachedPassword: typeof BlueApp.cachedPassword; + getItem: typeof BlueApp.getItem; + setItem: typeof BlueApp.setItem; + handleWalletDeletion: (walletID: string, forceDelete?: boolean) => Promise; + confirmWalletDeletion: (wallet: any, onConfirmed: () => void) => void; +} + +export enum WalletTransactionsStatus { + NONE = 'NONE', + ALL = 'ALL', +} + +// @ts-ignore default value does not match the type +export const StorageContext = createContext(undefined); + +export const StorageProvider = ({ children }: { children: React.ReactNode }) => { + const txMetadata = useRef(BlueApp.tx_metadata); + const counterpartyMetadata = useRef(BlueApp.counterparty_metadata || {}); // init + + const [wallets, setWallets] = useState([]); + const [walletTransactionUpdateStatus, setWalletTransactionUpdateStatus] = useState( + WalletTransactionsStatus.NONE, + ); + const [walletsInitialized, setWalletsInitialized] = useState(false); + const [currentSharedCosigner, setCurrentSharedCosigner] = useState(''); + + const selectedWalletID = useCallback((): string | undefined => { + if (!navigationRef.current || !navigationRef.current.isReady()) return undefined; + + const screensToCheck = ['LNDCreateInvoice', 'SendDetails', 'WalletTransactions', 'TransactionStatus']; + + const currentRoute = navigationRef.current.getCurrentRoute(); + console.debug('[StorageProvider] Current route:', currentRoute?.name); + + if (currentRoute) { + if (screensToCheck.includes(currentRoute.name) && currentRoute.params) { + const params = currentRoute.params as { walletID?: string }; + if (params.walletID) { + console.debug('[StorageProvider] selectedWalletID from current route:', params.walletID); + return params.walletID; + } + } + } + + const state = navigationRef.current.getState(); + + if (state?.routes) { + for (const screenName of screensToCheck) { + const walletID = findWalletIDInNavigationState(state.routes, screenName); + if (walletID) { + console.debug('[StorageProvider] selectedWalletID from navigation state:', walletID, 'in screen:', screenName); + return walletID; + } + } + + const drawerRoute = state.routes.find(route => route.name === 'DrawerRoot'); + if (drawerRoute?.state?.routes) { + const detailViewStack = drawerRoute.state.routes.find(route => route.name === 'DetailViewStackScreensStack'); + if (detailViewStack?.state?.routes) { + for (const route of detailViewStack.state.routes) { + if (screensToCheck.includes(route.name) && (route.params as { walletID?: string })?.walletID) { + console.debug( + '[StorageProvider] selectedWalletID from drawer navigation:', + (route.params as { walletID?: string })?.walletID, + ); + return (route.params as { walletID?: string })?.walletID; + } + } + } + } + } + + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const findWalletIDInNavigationState = (routes: any[], screenName: string): string | undefined => { + for (let i = routes.length - 1; i >= 0; i--) { + const route = routes[i]; + + if (route.name === screenName && (route.params as { walletID?: string }).walletID) { + return (route.params as { walletID?: string }).walletID; + } + + if (route.state?.routes) { + const walletID = findWalletIDInNavigationState(route.state.routes, screenName); + if (walletID) return walletID; + } + + if (route.params?.screen === screenName && route.params?.params?.walletID) { + return route.params.params.walletID; + } + + if (route.name === 'DetailViewStackScreensStack' && route.params?.screen === screenName && route.params?.params?.walletID) { + return route.params.params.walletID; + } + } + + return undefined; + }; + + const saveToDisk = useCallback( + async (force: boolean = false) => { + if (!force && BlueApp.getWallets().length === 0) { + console.debug('Not saving empty wallets array'); + return; + } + BlueApp.tx_metadata = txMetadata.current; + BlueApp.counterparty_metadata = counterpartyMetadata.current; + await BlueApp.saveToDisk(); + const w: TWallet[] = [...BlueApp.getWallets()]; + setWallets(w); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [txMetadata.current, counterpartyMetadata.current], + ); + + const addWallet = useCallback((wallet: TWallet) => { + BlueApp.wallets.push(wallet); + setWallets([...BlueApp.getWallets()]); + }, []); + + const deleteWallet = useCallback((wallet: TWallet) => { + BlueApp.deleteWallet(wallet); + setWallets([...BlueApp.getWallets()]); + if (wallet.type === LightningArkWallet.type) { + // Fire-and-forget: cleans up the per-wallet Arkade Realm (close + delete files) + // and the Keychain encryption key. Errors stay scoped to the Ark wallet path + // and never block deletion. + (wallet as LightningArkWallet).onDelete().catch(e => console.warn('[StorageProvider] Ark wallet cleanup failed:', e?.message ?? e)); + if (!BlueApp.getWallets().some(w => w.type === LightningArkWallet.type)) { + stopArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task stop failed:', e?.message ?? e)); + } + } + }, []); + + const handleWalletDeletion = useCallback( + async (walletID: string, forceDelete = false): Promise => { + console.debug(`handleWalletDeletion: invoked for walletID ${walletID}`); + const wallet = wallets.find(w => w.getID() === walletID); + if (!wallet) { + console.warn(`handleWalletDeletion: wallet not found for ${walletID}`); + return false; + } + + if (forceDelete) { + deleteWallet(wallet); + await saveToDisk(true); + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + return true; + } + + let isNotificationsSettingsEnabled = false; + try { + isNotificationsSettingsEnabled = await isNotificationsEnabled(); + } catch (error) { + console.error(`handleWalletDeletion: error checking notifications for wallet ${walletID}`, error); + return await new Promise(resolve => { + presentAlert({ + title: loc.errors.error, + message: loc.wallets.details_delete_wallet_error_message, + buttons: [ + { + text: loc.wallets.details_delete_anyway, + onPress: async () => { + const result = await handleWalletDeletion(walletID, true); + resolve(result); + }, + style: 'destructive', + }, + { + text: loc.wallets.list_tryagain, + onPress: async () => { + const result = await handleWalletDeletion(walletID); + resolve(result); + }, + }, + { + text: loc._.cancel, + onPress: () => resolve(false), + style: 'cancel', + }, + ], + options: { cancelable: false }, + }); + }); + } + + try { + if (isNotificationsSettingsEnabled) { + const externalAddresses = wallet.getAllExternalAddresses(); + if (externalAddresses.length > 0) { + console.debug(`handleWalletDeletion: unsubscribing addresses for wallet ${walletID}`); + try { + await unsubscribe(externalAddresses, [], []); + console.debug(`handleWalletDeletion: unsubscribe succeeded for wallet ${walletID}`); + } catch (unsubscribeError) { + console.error(`handleWalletDeletion: unsubscribe failed for wallet ${walletID}`, unsubscribeError); + presentAlert({ + title: loc.errors.error, + message: loc.wallets.details_delete_wallet_error_message, + buttons: [{ text: loc._.ok, onPress: () => {} }], + options: { cancelable: false }, + }); + return false; + } + } + } + deleteWallet(wallet); + console.debug(`handleWalletDeletion: wallet ${walletID} deleted successfully`); + await saveToDisk(true); + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + return true; + } catch (e: unknown) { + console.error(`handleWalletDeletion: encountered error for wallet ${walletID}`, e); + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + return await new Promise(resolve => { + presentAlert({ + title: loc.errors.error, + message: loc.wallets.details_delete_wallet_error_message, + buttons: [ + { + text: loc.wallets.details_delete_anyway, + onPress: async () => { + const result = await handleWalletDeletion(walletID, true); + resolve(result); + }, + style: 'destructive', + }, + { + text: loc.wallets.list_tryagain, + onPress: async () => { + const result = await handleWalletDeletion(walletID); + resolve(result); + }, + }, + { + text: loc._.cancel, + onPress: () => resolve(false), + style: 'cancel', + }, + ], + options: { cancelable: false }, + }); + }); + } + }, + [deleteWallet, saveToDisk, wallets], + ); + + const resetWallets = useCallback(() => { + setWallets(BlueApp.getWallets()); + }, []); + + const setWalletsWithNewOrder = useCallback( + (wlts: TWallet[]) => { + BlueApp.wallets = wlts; + saveToDisk(); + }, + [saveToDisk], + ); + + // Initialize wallets + useEffect(() => { + if (walletsInitialized) { + txMetadata.current = BlueApp.tx_metadata; + counterpartyMetadata.current = BlueApp.counterparty_metadata; + const loaded = BlueApp.getWallets(); + setWallets(loaded); + if (loaded.some(w => w.type === LightningArkWallet.type)) { + registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e)); + } + } + }, [walletsInitialized]); + + // Add a refresh lock to prevent concurrent refreshes + const refreshingRef = useRef(false); + + const refreshAllWalletTransactions = useCallback( + async (lastSnappedTo?: number, showUpdateStatusIndicator: boolean = true) => { + if (refreshingRef.current) { + console.debug('[refreshAllWalletTransactions] Refresh already in progress'); + return; + } + console.debug('[refreshAllWalletTransactions] Starting refresh'); + refreshingRef.current = true; + + let refreshTimeout: ReturnType | undefined; + + try { + if (showUpdateStatusIndicator) { + console.debug('[refreshAllWalletTransactions] Setting wallet transaction status to ALL'); + setWalletTransactionUpdateStatus(WalletTransactionsStatus.ALL); + } + console.debug('[refreshAllWalletTransactions] Waiting for connectivity...'); + // `ensureConnected()` ping-checks the existing socket and, only if needed, + // tears it down and reconnects. Replaces the old wait+ping+wait pattern + // which surfaced false "network error" alerts after iOS suspend/resume. + const connected = await BlueElectrum.ensureConnected(); + if (!connected) { + console.log('[refreshAllWalletTransactions] could not establish Electrum connection, aborting refresh'); + return; + } + + console.debug('[refreshAllWalletTransactions] Connected to Electrum'); + + // Race only the post-connect work. We budget ample time so that a slow + // initial Electrum connection (cold start, slow TLS, flaky network) doesn't + // cause the fetch race to abort prematurely. + const REFRESH_FETCH_PHASE_TIMEOUT_MS = Math.max(120_000, BlueElectrum.ENSURE_CONNECTED_MAX_WALL_MS * 2); + const timeoutPromise = new Promise( + (_resolve, reject) => + (refreshTimeout = setTimeout(() => { + console.debug('[refreshAllWalletTransactions] Timeout reached'); + reject(new Error('Timeout reached')); + }, REFRESH_FETCH_PHASE_TIMEOUT_MS)), + ); + + if (typeof BlueApp.fetchSenderPaymentCodes !== 'function') { + console.warn('[refreshAllWalletTransactions] fetchSenderPaymentCodes is not available'); + } + + const paymentCodesPromise = + typeof BlueApp.fetchSenderPaymentCodes === 'function' + ? (async () => { + const codesStart = Date.now(); + console.debug('[refreshAllWalletTransactions] Fetching sender payment codes (parallel)'); + await BlueApp.fetchSenderPaymentCodes(lastSnappedTo); + console.debug('[refreshAllWalletTransactions] fetch payment codes took', (Date.now() - codesStart) / 1000, 'sec'); + })() + : Promise.resolve(); + + console.debug('[refreshAllWalletTransactions] Fetching wallet balances and transactions'); + await Promise.race([ + (async () => { + await Promise.all([ + paymentCodesPromise, + (async () => { + const balanceStart = Date.now(); + await BlueApp.fetchWalletBalances(lastSnappedTo); + console.debug('[refreshAllWalletTransactions] fetch balance took', (Date.now() - balanceStart) / 1000, 'sec'); + + const txStart = Date.now(); + await BlueApp.fetchWalletTransactions(lastSnappedTo); + console.debug('[refreshAllWalletTransactions] fetch tx took', (Date.now() - txStart) / 1000, 'sec'); + })(), + ]); + + console.debug('[refreshAllWalletTransactions] Saving data to disk'); + await saveToDisk(); + })(), + timeoutPromise, + ]); + console.debug('[refreshAllWalletTransactions] Refresh completed successfully'); + } catch (error) { + console.error('[refreshAllWalletTransactions] Error:', error); + } finally { + if (refreshTimeout !== undefined) { + clearTimeout(refreshTimeout); + } + console.debug('[refreshAllWalletTransactions] Resetting wallet transaction status and refresh lock'); + refreshingRef.current = false; + setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); + } + }, + [saveToDisk], + ); + + const fetchAndSaveWalletTransactions = useCallback( + async (walletID: string) => { + const index = wallets.findIndex(wallet => wallet.getID() === walletID); + let noErr = true; + try { + if (Date.now() - (_lastTimeTriedToRefetchWallet[walletID] || 0) < 5000) { + console.debug('[fetchAndSaveWalletTransactions] Re-fetch wallet happens too fast; NOP'); + return; + } + _lastTimeTriedToRefetchWallet[walletID] = Date.now(); + + const connected = await BlueElectrum.ensureConnected(); + if (!connected) { + console.log('[fetchAndSaveWalletTransactions] could not establish Electrum connection, aborting'); + return; + } + setWalletTransactionUpdateStatus(walletID); + + const balanceStart = Date.now(); + await BlueApp.fetchWalletBalances(index); + const balanceEnd = Date.now(); + console.debug('[fetchAndSaveWalletTransactions] fetch balance took', (balanceEnd - balanceStart) / 1000, 'sec'); + + const txStart = Date.now(); + await BlueApp.fetchWalletTransactions(index); + const txEnd = Date.now(); + console.debug('[fetchAndSaveWalletTransactions] fetch tx took', (txEnd - txStart) / 1000, 'sec'); + } catch (err) { + noErr = false; + console.error('[fetchAndSaveWalletTransactions] Error:', err); + } finally { + setWalletTransactionUpdateStatus(WalletTransactionsStatus.NONE); + } + if (noErr) await saveToDisk(); + }, + [saveToDisk, wallets], + ); + + const addAndSaveWallet = useCallback( + async (w: TWallet) => { + if (wallets.some(i => i.getID() === w.getID())) { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: 'This wallet has been previously imported.' }); + return; + } + const emptyWalletLabel = new LegacyWallet().getLabel(); + if (w.getLabel() === emptyWalletLabel) w.setLabel(loc.wallets.import_imported + ' ' + w.typeReadable); + w.setUserHasSavedExport(true); + addWallet(w); + if (w instanceof LightningArkWallet) { + registerArkBackgroundTask().catch(e => console.warn('[StorageProvider] Ark background task register failed:', e?.message ?? e)); + } + if (getScanWasBBQR()) { + // to avoid proxying `useBBQR` through a bunch of screens during import procedure, we use a trick: + // on add-wallet screen we reset `lastScanWasBBQR` to false. then potentially user scans QR in BBQR format + // and saves his wallet to storage, in which case execution lands here, where we check last scan and save walletID + // internally as a marker that this wallet should display animated QR codes in this format + await setWalletIdMustUseBBQR(w.getID()); + } + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + await saveToDisk(); + + presentAlert({ + hapticFeedback: HapticFeedbackTypes.ImpactHeavy, + message: w.type === WatchOnlyWallet.type ? loc.wallets.import_success_watchonly : loc.wallets.import_success, + }); + + await w.fetchBalance(); + try { + await majorTomToGroundControl(w.getAllExternalAddresses(), [], []); + } catch (error) { + console.warn('Failed to setup notifications:', error); + // Consider if user should be notified of notification setup failure + } + }, + [wallets, addWallet, saveToDisk], + ); + + function confirmWalletDeletion(wallet: any, onConfirmed: () => void) { + triggerHapticFeedback(HapticFeedbackTypes.NotificationWarning); + try { + const balance = formatBalanceWithoutSuffix(wallet.getBalance(), BitcoinUnit.SATS, true); + presentAlert({ + title: loc.wallets.details_delete_wallet, + message: loc.formatString(loc.wallets.details_del_wb_q, { balance }), + buttons: [ + { + text: loc.wallets.details_delete, + onPress: () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + onConfirmed(); + }, + style: 'destructive', + }, + { + text: loc._.cancel, + onPress: () => {}, + style: 'cancel', + }, + ], + options: { cancelable: false }, + }); + } catch (error) { + // Handle error silently if needed + } + } + + const value: StorageContextType = useMemo( + () => ({ + wallets, + setWalletsWithNewOrder, + txMetadata: txMetadata.current, + counterpartyMetadata: counterpartyMetadata.current, + saveToDisk, + getTransactions: BlueApp.getTransactions, + selectedWalletID, + addWallet, + deleteWallet, + currentSharedCosigner, + setSharedCosigner: setCurrentSharedCosigner, + addAndSaveWallet, + setItem: BlueApp.setItem, + getItem: BlueApp.getItem, + fetchWalletBalances: BlueApp.fetchWalletBalances, + fetchWalletTransactions: BlueApp.fetchWalletTransactions, + fetchAndSaveWalletTransactions, + isStorageEncrypted: BlueApp.storageIsEncrypted, + encryptStorage: BlueApp.encryptStorage, + startAndDecrypt, + cachedPassword: BlueApp.cachedPassword, + getBalance: BlueApp.getBalance, + walletsInitialized, + setWalletsInitialized, + refreshAllWalletTransactions, + sleep: BlueApp.sleep, + createFakeStorage: BlueApp.createFakeStorage, + resetWallets, + decryptStorage: BlueApp.decryptStorage, + isPasswordInUse: BlueApp.isPasswordInUse, + walletTransactionUpdateStatus, + setWalletTransactionUpdateStatus, + handleWalletDeletion, + confirmWalletDeletion, + }), + [ + wallets, + setWalletsWithNewOrder, + saveToDisk, + selectedWalletID, + addWallet, + deleteWallet, + currentSharedCosigner, + addAndSaveWallet, + fetchAndSaveWalletTransactions, + walletsInitialized, + setWalletsInitialized, + refreshAllWalletTransactions, + resetWallets, + walletTransactionUpdateStatus, + handleWalletDeletion, + ], + ); + + return {children}; +}; diff --git a/components/CopyTextToClipboard.tsx b/components/CopyTextToClipboard.tsx new file mode 100644 index 00000000000..33398e9def2 --- /dev/null +++ b/components/CopyTextToClipboard.tsx @@ -0,0 +1,256 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import { StyleSheet, Text, TextProps, TextStyle, TouchableOpacity, View, ViewStyle } from 'react-native'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import BlueText from './BlueText'; +import loc from '../loc'; +import { useTheme } from './themes'; + +export type CopyTextToClipboardHandle = { + copy: (options?: { suppressHaptic?: boolean }) => void; +}; + +type CopyTextToClipboardProps = TextProps & { + text: string; + displayText?: string; // Optional text to display instead of the actual text (but still copies the actual text) + truncated?: boolean; + selectable?: boolean; + textAlign?: 'left' | 'center' | 'right' | 'auto' | 'justify'; + containerStyle?: ViewStyle; + copiedContainerStyle?: ViewStyle; + copiedTextStyle?: TextStyle; + isAddress?: boolean; + interactive?: boolean; + buttonTestID?: string; + textTestID?: string; +}; + +const styles = StyleSheet.create({ + defaultTextStyle: { + marginVertical: 32, + fontSize: 15, + color: '#9aa0aa', + textAlign: 'center', + }, + addressDefaultTextStyle: { + fontSize: 15, + color: '#9aa0aa', + textAlign: 'center', + }, + textFillContainer: { + width: '100%', + minWidth: 0, + }, + nonInteractiveContainer: { + justifyContent: 'center', + alignItems: 'center', + }, +}); + +const COPY_FEEDBACK_MS = 1500; + +const CopyTextToClipboard = forwardRef( + ( + { + text, + displayText: displayTextProp, + truncated, + style, + numberOfLines, + ellipsizeMode, + selectable, + textAlign, + containerStyle, + copiedContainerStyle, + copiedTextStyle, + accessibilityLabel, + isAddress, + interactive = true, + buttonTestID = 'CopyTextToClipboard', + textTestID = 'AddressValue', + ...textProps + }, + ref, + ) => { + const [hasTappedText, setHasTappedText] = useState(false); + const initialDisplayText = displayTextProp || text; + const [displayText, setDisplayText] = useState(initialDisplayText); + const isCopiedState = hasTappedText && displayText === loc._.copied; + const { colors } = useTheme(); + const copyResetTimeoutRef = useRef | null>(null); + + const addressSectionStyle = useMemo( + () => ({ + color: colors.alternativeTextColor2, + fontWeight: '500', + }), + [colors.alternativeTextColor2], + ); + + useEffect(() => { + if (!hasTappedText) { + setDisplayText(displayTextProp || text); + } + }, [text, displayTextProp, hasTappedText]); + + useEffect( + () => () => { + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + copyResetTimeoutRef.current = null; + } + }, + [], + ); + + const copyToClipboard = useCallback( + (options?: { suppressHaptic?: boolean }) => { + // Don't copy if already showing the copied state, or text is empty / "-" + if (hasTappedText || !text || text === '-') { + return; + } + + if (copyResetTimeoutRef.current) { + clearTimeout(copyResetTimeoutRef.current); + copyResetTimeoutRef.current = null; + } + + setHasTappedText(true); + Clipboard.setString(text); + if (!options?.suppressHaptic) { + triggerHapticFeedback(HapticFeedbackTypes.Selection); + } + setDisplayText(loc._.copied); + copyResetTimeoutRef.current = setTimeout(() => { + copyResetTimeoutRef.current = null; + setHasTappedText(false); + setDisplayText(displayTextProp || text); + }, COPY_FEEDBACK_MS); + }, + [hasTappedText, text, displayTextProp], + ); + + useImperativeHandle(ref, () => ({ copy: copyToClipboard }), [copyToClipboard]); + + /** Single-line value for screen readers / Detox `by.label` when visual text uses newlines or splits (e.g. receive address). */ + const accessibilityLabelResolved = accessibilityLabel ?? (isCopiedState ? loc._.copied : text); + + const mergedTextStyle = style ?? (isAddress ? styles.addressDefaultTextStyle : styles.defaultTextStyle); + const textAlignStyle = textAlign ? { textAlign } : undefined; + const finalNumberOfLines = isCopiedState ? 1 : numberOfLines !== undefined ? numberOfLines : truncated ? 1 : 0; + const finalEllipsizeMode = isCopiedState ? undefined : ellipsizeMode || (truncated ? 'middle' : undefined); + const resolvedContainerStyle = isCopiedState && copiedContainerStyle ? [containerStyle, copiedContainerStyle] : containerStyle; + const resolvedTextStyle = isCopiedState && copiedTextStyle ? [mergedTextStyle, textAlignStyle, copiedTextStyle] : null; + + const textStyleArray = + resolvedTextStyle ?? + (containerStyle && !isCopiedState ? [mergedTextStyle, styles.textFillContainer, textAlignStyle] : [mergedTextStyle, textAlignStyle]); + + const renderHighlightedAddress = () => { + // While showing the "Copied!" feedback, render plain text without highlights. + if (isCopiedState) { + return ( + + {displayText} + + ); + } + + if (displayText.toLocaleLowerCase().startsWith('bitcoin:')) { + const prefix = displayText.slice(0, 8); // "bitcoin:" + const afterPrefix = displayText.slice(8); + const qIndex = afterPrefix.indexOf('?'); + const addrPart = qIndex === -1 ? afterPrefix : afterPrefix.slice(0, qIndex); + const queryPart = qIndex === -1 ? '' : afterPrefix.slice(qIndex); + const start = addrPart.slice(0, 6); + const middle = addrPart.slice(6, -6); + const end = addrPart.slice(-6); + + return ( + + {prefix} + {start} + {middle} + {end} + {queryPart} + + ); + } + + return ( + + {displayText.slice(0, 6)} + {displayText.slice(6, -6)} + {displayText.slice(-6)} + + ); + }; + + const textContent = isAddress ? ( + renderHighlightedAddress() + ) : ( + + {displayText} + + ); + + if (!interactive) { + return ( + + {textContent} + + ); + } + + return ( + copyToClipboard()} + disabled={hasTappedText || !text || text === '-'} + testID={buttonTestID} + activeOpacity={0.7} + style={resolvedContainerStyle} + > + {resolvedContainerStyle ? {textContent} : textContent} + + ); + }, +); + +export default CopyTextToClipboard; diff --git a/components/CopyToClipboardButton.tsx b/components/CopyToClipboardButton.tsx new file mode 100644 index 00000000000..41e8cb69171 --- /dev/null +++ b/components/CopyToClipboardButton.tsx @@ -0,0 +1,30 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; + +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; + +type CopyToClipboardButtonProps = { + stringToCopy: string; + displayText?: string; +}; + +export const CopyToClipboardButton: React.FC = ({ stringToCopy, displayText }) => { + const onPress = () => { + Clipboard.setString(stringToCopy); + triggerHapticFeedback(HapticFeedbackTypes.Selection); + }; + + return ( + + {displayText && displayText.length > 0 ? displayText : loc.transactions.details_copy} + + ); +}; + +const styles = StyleSheet.create({ + text: { fontSize: 16, fontWeight: '400', color: '#68bbe1' }, +}); + +export default CopyToClipboardButton; diff --git a/components/DevMenu.tsx b/components/DevMenu.tsx new file mode 100644 index 00000000000..2613d82424f --- /dev/null +++ b/components/DevMenu.tsx @@ -0,0 +1,168 @@ +import React, { useEffect } from 'react'; +import { DevSettings, Alert, Platform, AlertButton } from 'react-native'; +import { useStorage } from '../hooks/context/useStorage'; +import { HDSegwitBech32Wallet } from '../class/wallets/hd-segwit-bech32-wallet'; +import { WatchOnlyWallet } from '../class/wallets/watch-only-wallet'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { TWallet } from '../class/wallets/types'; + +const getRandomLabelFromSecret = (secret: string): string => { + const words = secret.split(' '); + const firstWord = words[0]; + const lastWord = words[words.length - 1]; + return `[Developer] ${firstWord} ${lastWord}`; +}; + +const showAlertWithWalletOptions = ( + wallets: TWallet[], + title: string, + message: string, + onWalletSelected: (wallet: TWallet) => void, + filterFn?: (wallet: TWallet) => boolean, +) => { + const filteredWallets = filterFn ? wallets.filter(filterFn) : wallets; + + const showWallet = (index: number) => { + if (index >= filteredWallets.length) return; + const wallet = filteredWallets[index]; + + if (Platform.OS === 'android') { + // Android: Use a limited number of buttons since the alert dialog has a limit + Alert.alert( + `${title}: ${wallet.getLabel()}`, + `${message}\n\nSelected Wallet: ${wallet.getLabel()}\n\nWould you like to select this wallet or see the next one?`, + [ + { + text: 'Select This Wallet', + onPress: () => onWalletSelected(wallet), + }, + { + text: 'Show Next Wallet', + onPress: () => showWallet(index + 1), + }, + { + text: 'Cancel', + style: 'cancel', + }, + ], + { cancelable: true }, + ); + } else { + const options: AlertButton[] = filteredWallets.map(w => ({ + text: w.getLabel(), + onPress: () => onWalletSelected(w), + })); + + options.push({ + text: 'Cancel', + style: 'cancel', + }); + + Alert.alert(title, message, options, { cancelable: true }); + } + }; + + if (filteredWallets.length > 0) { + showWallet(0); + } else { + Alert.alert('No wallets available'); + } +}; + +const DevMenu: React.FC = () => { + const { wallets, addWallet } = useStorage(); + + useEffect(() => { + if (__DEV__) { + // Clear existing Dev Menu items to prevent duplication + DevSettings.addMenuItem('Reset Dev Menu', () => { + DevSettings.reload(); + }); + + DevSettings.addMenuItem('Add New Wallet', async () => { + const wallet = new HDSegwitBech32Wallet(); + await wallet.generate(); + const label = getRandomLabelFromSecret(wallet.getSecret()); + wallet.setLabel(label); + addWallet(wallet); + + Clipboard.setString(wallet.getSecret()); + Alert.alert('New Wallet created!', `Wallet secret copied to clipboard.\nLabel: ${label}`); + }); + + DevSettings.addMenuItem('Copy Wallet Secret', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Copy Wallet Secret', 'Select the wallet to copy the secret', wallet => { + Clipboard.setString(wallet.getSecret()); + Alert.alert('Wallet Secret copied to clipboard!'); + }); + }); + + DevSettings.addMenuItem('Copy Wallet ID', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Copy Wallet ID', 'Select the wallet to copy the ID', wallet => { + Clipboard.setString(wallet.getID()); + Alert.alert('Wallet ID copied to clipboard!'); + }); + }); + + DevSettings.addMenuItem('Copy Wallet Xpub', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions( + wallets, + 'Copy Wallet Xpub', + 'Select the wallet to copy the Xpub', + wallet => { + const xpub = wallet.getXpub(); + if (xpub) { + Clipboard.setString(xpub); + Alert.alert('Wallet Xpub copied to clipboard!'); + } else { + Alert.alert('This wallet does not have an Xpub.'); + } + }, + wallet => typeof wallet.getXpub === 'function', + ); + }); + + DevSettings.addMenuItem('Purge Wallet Transactions', () => { + if (wallets.length === 0) { + Alert.alert('No wallets available'); + return; + } + + showAlertWithWalletOptions(wallets, 'Purge Wallet Transactions', 'Select the wallet to purge transactions', wallet => { + const msg = 'Transactions purged successfully!'; + + if (wallet.type === HDSegwitBech32Wallet.type) { + wallet._txs_by_external_index = {}; + wallet._txs_by_internal_index = {}; + } + + if (wallet.type === WatchOnlyWallet.type && wallet._hdWalletInstance) { + wallet._hdWalletInstance._txs_by_external_index = {}; + wallet._hdWalletInstance._txs_by_internal_index = {}; + } + + Alert.alert(msg); + }); + }); + } + }, [wallets, addWallet]); + + return null; +}; + +export default DevMenu; diff --git a/components/DismissKeyboardInputAccessory.tsx b/components/DismissKeyboardInputAccessory.tsx new file mode 100644 index 00000000000..c8a886b0807 --- /dev/null +++ b/components/DismissKeyboardInputAccessory.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native'; +import { useTheme } from './themes'; +import BlueButtonLink from './BlueButtonLink'; +import loc from '../loc'; + +export const DismissKeyboardInputAccessoryViewID = 'DismissKeyboardInputAccessory'; +export const DismissKeyboardInputAccessory: React.FC = () => { + const { colors } = useTheme(); + const styleHooks = StyleSheet.create({ + container: { + backgroundColor: colors.inputBackgroundColor, + }, + }); + + if (Platform.OS !== 'ios') { + return null; + } + + return ( + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + maxHeight: 44, + }, +}); diff --git a/components/Divider.tsx b/components/Divider.tsx new file mode 100644 index 00000000000..bdce6073148 --- /dev/null +++ b/components/Divider.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; + +import { useTheme } from './themes'; + +export interface DividerProps { + style?: StyleProp; + color?: string; +} + +const Divider: React.FC = ({ style, color }) => { + const { colors } = useTheme(); + const backgroundColor = color ?? colors.formBorder; + + return ; +}; + +const styles = StyleSheet.create({ + divider: { + height: StyleSheet.hairlineWidth, + alignSelf: 'stretch', + }, +}); + +export default Divider; diff --git a/components/DoneAndDismissKeyboardInputAccessory.tsx b/components/DoneAndDismissKeyboardInputAccessory.tsx new file mode 100644 index 00000000000..c6a1d598ab1 --- /dev/null +++ b/components/DoneAndDismissKeyboardInputAccessory.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, View } from 'react-native'; +import BlueButtonLink from './BlueButtonLink'; +import loc from '../loc'; +import { useTheme } from './themes'; +import Clipboard from '@react-native-clipboard/clipboard'; + +interface DoneAndDismissKeyboardInputAccessoryProps { + onPasteTapped: (clipboard: string) => void; + onClearTapped: () => void; +} +export const DoneAndDismissKeyboardInputAccessoryViewID = 'DoneAndDismissKeyboardInputAccessory'; +export const DoneAndDismissKeyboardInputAccessory: React.FC = props => { + const { colors } = useTheme(); + + const styleHooks = StyleSheet.create({ + container: { + backgroundColor: colors.inputBackgroundColor, + }, + }); + + const onPasteTapped = async () => { + const clipboard = await Clipboard.getString(); + props.onPasteTapped(clipboard); + }; + + const inputView = ( + + + + + + ); + + if (Platform.OS === 'ios') { + return {inputView}; + } else { + return inputView; + } +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + maxHeight: 44, + }, +}); diff --git a/components/DynamicQRCode.js b/components/DynamicQRCode.js deleted file mode 100644 index 67c77905746..00000000000 --- a/components/DynamicQRCode.js +++ /dev/null @@ -1,197 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import React, { Component } from 'react'; -import { Text } from 'react-native-elements'; -import { Dimensions, LayoutAnimation, StyleSheet, TouchableOpacity, View } from 'react-native'; -import { encodeUR } from '../blue_modules/ur'; -import QRCodeComponent from './QRCodeComponent'; -import { BlueCurrentTheme } from '../components/themes'; -import { BlueSpacing20 } from '../BlueComponents'; -import loc from '../loc'; - -const { height, width } = Dimensions.get('window'); - -export class DynamicQRCode extends Component { - constructor() { - super(); - const qrCodeHeight = height > width ? width - 40 : width / 3; - const qrCodeMaxHeight = 370; - this.state = { - index: 0, - total: 0, - qrCodeHeight: Math.min(qrCodeHeight, qrCodeMaxHeight), - intervalHandler: null, - displayQRCode: true, - }; - } - - fragments = []; - - componentDidMount() { - const { value, capacity = 200, hideControls = true } = this.props; - try { - this.fragments = encodeUR(value, capacity); - this.setState( - { - total: this.fragments.length, - hideControls, - displayQRCode: true, - }, - () => { - this.startAutoMove(); - }, - ); - } catch (e) { - console.log(e); - this.setState({ displayQRCode: false, hideControls }); - } - } - - moveToNextFragment = () => { - const { index, total } = this.state; - if (index === total - 1) { - this.setState({ - index: 0, - }); - } else { - this.setState(state => ({ - index: state.index + 1, - })); - } - }; - - startAutoMove = () => { - if (!this.state.intervalHandler) - this.setState(() => ({ - intervalHandler: setInterval(this.moveToNextFragment, 500), - })); - }; - - stopAutoMove = () => { - clearInterval(this.state.intervalHandler); - this.setState(() => ({ - intervalHandler: null, - })); - }; - - moveToPreviousFragment = () => { - const { index, total } = this.state; - if (index > 0) { - this.setState(state => ({ - index: state.index - 1, - })); - } else { - this.setState(state => ({ - index: total - 1, - })); - } - }; - - onError = () => { - console.log('Data is too large for QR Code.'); - this.setState({ displayQRCode: false }); - }; - - render() { - const currentFragment = this.fragments[this.state.index]; - - if (!currentFragment && this.state.displayQRCode) { - return ( - - {loc.send.dynamic_init} - - ); - } - - return ( - - { - LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); - this.setState(prevState => ({ hideControls: !prevState.hideControls })); - }} - > - {this.state.displayQRCode && ( - - - - )} - - - {!this.state.hideControls && ( - - - - - {loc.formatString(loc._.of, { number: this.state.index + 1, total: this.state.total })} - - - - - - {loc.send.dynamic_prev} - - - {this.state.intervalHandler ? loc.send.dynamic_stop : loc.send.dynamic_start} - - - {loc.send.dynamic_next} - - - - )} - - ); - } -} - -const animatedQRCodeStyle = StyleSheet.create({ - container: { - flex: 1, - flexDirection: 'column', - alignItems: 'center', - }, - qrcodeContainer: { - alignItems: 'center', - justifyContent: 'center', - }, - controller: { - width: '90%', - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - borderRadius: 25, - height: 45, - paddingHorizontal: 18, - }, - button: { - alignItems: 'center', - height: 45, - justifyContent: 'center', - }, - text: { - fontSize: 14, - color: BlueCurrentTheme.colors.foregroundColor, - fontWeight: 'bold', - }, -}); diff --git a/components/DynamicQRCode.tsx b/components/DynamicQRCode.tsx new file mode 100644 index 00000000000..0c7b68be04b --- /dev/null +++ b/components/DynamicQRCode.tsx @@ -0,0 +1,274 @@ +import React, { Component } from 'react'; +import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { encodeUR } from '../blue_modules/ur'; +import { BlueCurrentTheme } from '../components/themes'; +import loc from '../loc'; +import QRCode from './QRCode'; +import { BlueSpacing20 } from './BlueSpacing'; + +const { height, width } = Dimensions.get('window'); + +interface DynamicQRCodeProps { + value: string; + walletID?: string; + capacity?: number; + hideControls?: boolean; +} + +interface DynamicQRCodeState { + index: number; + total: number; + qrCodeHeight: number; + intervalHandler: ReturnType | number | null; + displayQRCode: boolean; + hideControls?: boolean; +} + +export class DynamicQRCode extends Component { + constructor(props: DynamicQRCodeProps) { + super(props); + const qrCodeHeight = height > width ? width - 40 : width / 3; + const qrCodeMaxHeight = 370; + this.state = { + index: 0, + total: 0, + qrCodeHeight: Math.min(qrCodeHeight, qrCodeMaxHeight), + intervalHandler: null, + displayQRCode: true, + }; + } + + fragments: string[] = []; + + componentWillUnmount() { + this.stopAutoMove(); + } + + componentDidMount() { + const { value, capacity = 175, hideControls = true, walletID } = this.props; + try { + this.fragments = encodeUR(value, capacity, walletID ?? null); + this.setState( + { + total: this.fragments.length, + hideControls, + displayQRCode: true, + }, + () => { + this.startAutoMove(); + }, + ); + } catch (e) { + console.log(e); + this.setState({ displayQRCode: false, hideControls }); + } + } + + moveToNextFragment = () => { + const { index, total } = this.state; + if (index === total - 1) { + this.setState({ + index: 0, + }); + } else { + this.setState(state => ({ + index: state.index + 1, + })); + } + }; + + forceUseBBQR = () => { + const { value, capacity = 175, hideControls = true, walletID } = this.props; + console.log({ value, capacity, walletID }); + + try { + this.fragments = encodeUR(value, capacity, walletID ?? null, 'BBQR'); + this.setState({ + total: this.fragments.length, + displayQRCode: true, + }); + } catch (e) { + console.log(e); + this.setState({ displayQRCode: false, hideControls }); + } + }; + + forceUseURv2 = () => { + const { value, capacity = 175, hideControls = true, walletID } = this.props; + console.log({ value, capacity, walletID }); + + try { + this.fragments = encodeUR(value, capacity, walletID ?? null, 'URv2'); + this.setState({ + total: this.fragments.length, + displayQRCode: true, + }); + } catch (e) { + console.log(e); + this.setState({ displayQRCode: false, hideControls }); + } + }; + + startAutoMove = () => { + if (!this.state.intervalHandler) + this.setState(() => ({ + intervalHandler: setInterval(this.moveToNextFragment, 1000), + })); + }; + + stopAutoMove = () => { + clearInterval(this.state.intervalHandler as number); + this.setState(() => ({ + intervalHandler: null, + })); + }; + + moveToPreviousFragment = () => { + const { index, total } = this.state; + if (index > 0) { + this.setState(state => ({ + index: state.index - 1, + })); + } else { + this.setState(state => ({ + index: total - 1, + })); + } + }; + + onError = () => { + console.log('Data is too large for QR Code.'); + this.setState({ displayQRCode: false }); + }; + + render() { + const currentFragment = this.fragments[this.state.index]; + + if (!currentFragment && this.state.displayQRCode) { + return ( + + {loc.send.dynamic_init} + + ); + } + + return ( + + { + this.setState(prevState => ({ hideControls: !prevState.hideControls })); + }} + > + {this.state.displayQRCode && ( + + + + )} + + + {!this.state.hideControls && ( + + + + + {loc.formatString(loc._.of, { number: this.state.index + 1, total: this.state.total })} + + + + + + {loc.send.dynamic_prev} + + + {this.state.intervalHandler ? loc.send.dynamic_stop : loc.send.dynamic_start} + + + {loc.send.dynamic_next} + + + + + + Force use BBQR + + + Force use URv2 + + + + )} + + ); + } +} + +const animatedQRCodeStyle = StyleSheet.create({ + container: { + flex: 1, + flexDirection: 'column', + alignItems: 'center', + }, + qrcodeContainer: { + alignItems: 'center', + justifyContent: 'center', + }, + controller: { + width: '90%', + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderRadius: 25, + height: 45, + paddingHorizontal: 18, + }, + controller2: { + flexDirection: 'column', + }, + button: { + alignItems: 'center', + height: 45, + justifyContent: 'center', + }, + buttonPrev: { + width: '25%', + alignItems: 'flex-start', + }, + buttonUseFormat: { + alignItems: 'center', + paddingTop: 25, + }, + buttonStopStart: { + width: '50%', + }, + buttonNext: { + width: '25%', + alignItems: 'flex-end', + }, + text: { + fontSize: 14, + color: BlueCurrentTheme.colors.foregroundColor, + fontWeight: 'bold', + }, +}); diff --git a/components/FloatButtons.js b/components/FloatButtons.js deleted file mode 100644 index 32ff1060e30..00000000000 --- a/components/FloatButtons.js +++ /dev/null @@ -1,138 +0,0 @@ -import React, { useState, useRef, forwardRef } from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, TouchableOpacity, StyleSheet, Dimensions, PixelRatio } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -const BORDER_RADIUS = 30; -const PADDINGS = 8; -const ICON_MARGIN = 7; - -const cStyles = StyleSheet.create({ - root: { - alignSelf: 'center', - height: '6.3%', - minHeight: 44, - }, - rootAbsolute: { - position: 'absolute', - bottom: 30, - }, - rootInline: {}, - rootPre: { - position: 'absolute', - bottom: -1000, - }, - rootPost: { - borderRadius: BORDER_RADIUS, - flexDirection: 'row', - overflow: 'hidden', - }, -}); - -export const FContainer = forwardRef((props, ref) => { - const [newWidth, setNewWidth] = useState(); - const layoutCalculated = useRef(false); - - const onLayout = event => { - if (layoutCalculated.current) return; - const maxWidth = Dimensions.get('window').width - BORDER_RADIUS - 20; - const { width } = event.nativeEvent.layout; - const withPaddings = Math.ceil(width + PADDINGS * 2); - const len = React.Children.toArray(props.children).filter(Boolean).length; - let newW = withPaddings * len > maxWidth ? Math.floor(maxWidth / len) : withPaddings; - if (len === 1 && newW < 90) newW = 90; // to add Paddings for lonely small button, like Scan on main screen - setNewWidth(newW); - layoutCalculated.current = true; - }; - - return ( - - {newWidth - ? React.Children.toArray(props.children) - .filter(Boolean) - .map((c, index, array) => - React.cloneElement(c, { - width: newWidth, - key: index, - first: index === 0, - last: index === array.length - 1, - }), - ) - : props.children} - - ); -}); - -FContainer.propTypes = { - children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.element), PropTypes.element]), - inline: PropTypes.bool, -}; - -const buttonFontSize = - PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26) > 22 - ? 22 - : PixelRatio.roundToNearestPixel(Dimensions.get('window').width / 26); - -const bStyles = StyleSheet.create({ - root: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - overflow: 'hidden', - }, - icon: { - alignItems: 'center', - }, - text: { - fontSize: buttonFontSize, - fontWeight: '600', - marginLeft: ICON_MARGIN, - backgroundColor: 'transparent', - }, -}); - -export const FButton = ({ text, icon, width, first, last, ...props }) => { - const { colors } = useTheme(); - const bStylesHook = StyleSheet.create({ - root: { - backgroundColor: colors.buttonBackgroundColor, - }, - text: { - color: colors.buttonAlternativeTextColor, - }, - textDisabled: { - color: colors.formBorder, - }, - }); - const style = {}; - - if (width) { - const paddingLeft = first ? BORDER_RADIUS / 2 : PADDINGS; - const paddingRight = last ? BORDER_RADIUS / 2 : PADDINGS; - style.paddingRight = paddingRight; - style.paddingLeft = paddingLeft; - style.width = width + paddingRight + paddingLeft; - } - - return ( - - {icon} - - {text} - - - ); -}; - -FButton.propTypes = { - text: PropTypes.string, - icon: PropTypes.element, - width: PropTypes.number, - first: PropTypes.bool, - last: PropTypes.bool, - disabled: PropTypes.bool, -}; diff --git a/components/FloatButtons.tsx b/components/FloatButtons.tsx new file mode 100644 index 00000000000..dc6512c05d8 --- /dev/null +++ b/components/FloatButtons.tsx @@ -0,0 +1,635 @@ +import React, { forwardRef, ReactNode, useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { Animated, PixelRatio, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View, StyleProp, TextStyle } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import LinearGradient from 'react-native-linear-gradient'; +import { useTheme } from './themes'; +import { useSizeClass, SizeClass } from '../blue_modules/sizeClass'; +import { isDesktop } from '../blue_modules/environment'; +import debounce from '../blue_modules/debounce'; +import { withAlpha } from './color'; + +const scheduleInNextFrame = (callback: () => void): number => { + return requestAnimationFrame(() => { + // Use a second requestAnimationFrame to ensure we're not in the same frame + requestAnimationFrame(callback); + }); +}; + +const LAYOUT = { + PADDINGS: 30, + ICON_MARGIN: 7, + BUTTON_MARGIN: 10, + MIN_BUTTON_WIDTH: 100, + MIN_BUTTON_WIDTH_LARGE: 130, + DRAWER_WIDTH: 320, + BUTTON_HEIGHT: 52, + CONTAINER_SIDE_MARGIN: 16, + PILL_BORDER_RADIUS: 100, + SINGLE_BUTTON_WIDTH_FACTOR: 0.625, + MAX_BUTTON_FONT_SIZE: 24, + SAFETY_MARGIN: 20, +}; + +const BUTTON_SCALE_PRESSED = 0.96; +const BUTTON_SCALE_ANIMATION_DURATION_MS = 110; + +const useFloatButtonAnimation = (initialHeight: number) => { + const slideAnimation = useRef(new Animated.Value(isDesktop ? 0 : initialHeight)).current; + + useEffect(() => { + if (isDesktop) return; + Animated.spring(slideAnimation, { + toValue: 0, + friction: 7, + tension: 40, + useNativeDriver: true, + }).start(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return { + slideAnimation, + }; +}; + +const getScaledButtonHeight = (fontScale: number): number => Math.round(LAYOUT.BUTTON_HEIGHT * fontScale); + +/** Gap between list content and the top of the float buttons. */ +const FLOAT_BUTTON_LIST_CLEARANCE = 18; + +const getFloatButtonBottomOffset = (bottomInset: number): number => (bottomInset ? bottomInset + 10 : 30); + +export const getFloatingButtonReservedHeight = (fontScale = 1, bottomInset = 0): number => + getScaledButtonHeight(fontScale) + FLOAT_BUTTON_LIST_CLEARANCE + getFloatButtonBottomOffset(bottomInset) - bottomInset; + +const useFloatButtonLayout = (width: number, sizeClass: SizeClass, fontScale: number) => { + const lastVerticalDecision = useRef(false); + + const shouldUseVerticalLayout = useCallback( + (totalWidthNeeded: number, availableWidth: number, totalChildren: number) => { + if (sizeClass !== SizeClass.Large || totalChildren <= 1) return false; + + const minWidthPerButton = 130; + const totalButtonsWidth = minWidthPerButton * totalChildren; + const totalSpacing = LAYOUT.BUTTON_MARGIN * (totalChildren - 1); + const minRequiredWidth = totalButtonsWidth + totalSpacing; + + const wouldBeTooNarrow = availableWidth < minRequiredWidth; + + if (!lastVerticalDecision.current && wouldBeTooNarrow) { + const shouldSwitch = availableWidth < minRequiredWidth * 0.9; + lastVerticalDecision.current = shouldSwitch; + return shouldSwitch; + } + + if (lastVerticalDecision.current && !wouldBeTooNarrow) { + const shouldSwitchBack = availableWidth > minRequiredWidth * 1.2; + lastVerticalDecision.current = !shouldSwitchBack; + return !shouldSwitchBack; + } + + return lastVerticalDecision.current; + }, + [sizeClass], + ); + + const calculateButtonWidth = useCallback( + (containerWidth: number, totalChildren: number): number => { + if (containerWidth <= 0) return 0; + + const drawerOffset = sizeClass === SizeClass.Large ? LAYOUT.DRAWER_WIDTH : 0; + const availableWidth = width - drawerOffset - LAYOUT.CONTAINER_SIDE_MARGIN * 2; + + const contentWidth = Math.ceil(containerWidth); + const buttonWidth = contentWidth + LAYOUT.PADDINGS * 2; + const totalButtonWidth = buttonWidth * totalChildren; + const totalSpacersWidth = (totalChildren - 1) * LAYOUT.BUTTON_MARGIN; + const totalWidthNeeded = totalButtonWidth + totalSpacersWidth + LAYOUT.SAFETY_MARGIN; + + const effectiveMinButtonWidth = + sizeClass === SizeClass.Large + ? LAYOUT.MIN_BUTTON_WIDTH_LARGE + : sizeClass === SizeClass.Regular + ? LAYOUT.MIN_BUTTON_WIDTH + : LAYOUT.MIN_BUTTON_WIDTH * 0.85; + + const shouldBeVertical = shouldUseVerticalLayout(totalWidthNeeded, availableWidth, totalChildren); + + let calculatedWidth; + + if (shouldBeVertical) { + calculatedWidth = sizeClass === SizeClass.Large ? availableWidth - LAYOUT.CONTAINER_SIDE_MARGIN * 2 : availableWidth; + } else { + if (totalWidthNeeded > availableWidth) { + const availableWidthPerButton = (availableWidth - totalSpacersWidth) / totalChildren; + calculatedWidth = Math.floor(availableWidthPerButton) - LAYOUT.PADDINGS * 2; + } else { + calculatedWidth = Math.max(contentWidth, effectiveMinButtonWidth); + } + } + + if (totalChildren === 1 && !shouldBeVertical) { + const singleButtonMaxWidth = availableWidth * (sizeClass === SizeClass.Compact ? 0.7 : LAYOUT.SINGLE_BUTTON_WIDTH_FACTOR); + const effectiveSingleMinWidth = sizeClass === SizeClass.Large ? LAYOUT.MIN_BUTTON_WIDTH * 1.2 : LAYOUT.MIN_BUTTON_WIDTH; + + calculatedWidth = Math.max( + effectiveSingleMinWidth - LAYOUT.PADDINGS * 2, + Math.min(calculatedWidth, singleButtonMaxWidth - LAYOUT.PADDINGS * 2), + ); + } + + return Math.floor(calculatedWidth); + }, + [width, sizeClass, shouldUseVerticalLayout], + ); + + const calculateVisualParameters = useCallback( + (calculatedWidth: number, totalChildren: number) => { + const drawerOffset = sizeClass === SizeClass.Large ? LAYOUT.DRAWER_WIDTH : 0; + const availableWidth = width - drawerOffset - LAYOUT.CONTAINER_SIDE_MARGIN * 2; + + const buttonWidth = Math.max(calculatedWidth, LAYOUT.MIN_BUTTON_WIDTH_LARGE) + LAYOUT.PADDINGS * 2; + const totalButtonWidth = buttonWidth * totalChildren; + const totalSpacersWidth = (totalChildren - 1) * LAYOUT.BUTTON_MARGIN; + const totalWidthNeeded = totalButtonWidth + totalSpacersWidth; + + const shouldBeVertical = shouldUseVerticalLayout(totalWidthNeeded, availableWidth, totalChildren); + + const buttonRadius = LAYOUT.PILL_BORDER_RADIUS; + + return { buttonRadius, shouldBeVertical }; + }, + [width, sizeClass, shouldUseVerticalLayout], + ); + + const calculateContainerHeight = useCallback( + (childrenCount: number, isVerticalLayout: boolean) => { + const buttonHeight = getScaledButtonHeight(fontScale); + if (!isVerticalLayout) return { height: '8%', minHeight: buttonHeight }; + + const totalButtonsHeight = childrenCount * buttonHeight; + const totalMarginsHeight = (childrenCount - 1) * LAYOUT.BUTTON_MARGIN; + const calculatedHeight = totalButtonsHeight + totalMarginsHeight; + + return { height: calculatedHeight }; + }, + [fontScale], + ); + + const calculateButtonFontSize = useMemo(() => { + const divisor = sizeClass === SizeClass.Large ? 22 : sizeClass === SizeClass.Regular ? 24 : 28; + const baseSize = PixelRatio.roundToNearestPixel(width / divisor); + return Math.min(LAYOUT.MAX_BUTTON_FONT_SIZE, baseSize); + }, [width, sizeClass]); + + return { + calculateButtonWidth, + calculateVisualParameters, + calculateContainerHeight, + buttonFontSize: calculateButtonFontSize, + }; +}; + +const containerStyles = StyleSheet.create({ + root: { + alignSelf: 'center', + height: '8%', + minHeight: LAYOUT.BUTTON_HEIGHT, + marginHorizontal: LAYOUT.CONTAINER_SIDE_MARGIN, + }, + rootAbsolute: { + position: 'absolute', + }, + rootInline: {}, + rootPre: { + position: 'absolute', + bottom: -1000, + }, + rootPost: { + flexDirection: 'row', + overflow: 'hidden', + }, + rootPostVertical: { + flexDirection: 'column', + overflow: 'hidden', + }, + childWrapper: { + width: '100%', + }, +}); + +const buttonStyles = StyleSheet.create({ + root: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + overflow: 'hidden', + }, + iconContainer: { + alignItems: 'center', + justifyContent: 'center', + minWidth: 24, + minHeight: 24, + overflow: 'visible', + alignSelf: 'center', + }, + centeredText: { + textAlign: 'center', + textAlignVertical: 'center', + }, +}); + +const buttonContentStaticStyles = StyleSheet.create({ + root: { + height: LAYOUT.BUTTON_HEIGHT, + overflow: 'hidden', + justifyContent: 'center', + }, + marginRight: { + marginRight: LAYOUT.BUTTON_MARGIN, + }, + marginBottom: { + marginBottom: LAYOUT.BUTTON_MARGIN, + }, + textBase: { + fontWeight: '600', + marginLeft: LAYOUT.ICON_MARGIN, + backgroundColor: 'transparent', + textAlign: 'center', + textAlignVertical: 'center', + }, + contentContainer: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + width: '100%', + height: '100%', + }, +}); + +interface FContainerProps { + children: ReactNode | ReactNode[]; + inline?: boolean; +} + +interface FButtonProps { + text: string; + icon: ReactNode; + width?: number; + last?: boolean; + singleChild?: boolean; + isVertical?: boolean; + borderRadius?: number; + fontSize?: number; + buttonHeight?: number; + disabled?: boolean; + testID?: string; + onPress: () => void; + onLongPress?: () => void; +} + +interface ButtonContentProps { + icon: ReactNode; + text: string; + textStyle: StyleProp; + buttonHeight: number; +} + +const getScaledIconSize = (fontSize: number): number => { + return Math.max(Math.round(fontSize * 1.2), 16); +}; + +const ButtonContent = ({ icon, text, textStyle, buttonHeight }: ButtonContentProps) => { + const computedStyle = StyleSheet.flatten(textStyle); + const fontSize = computedStyle.fontSize || LAYOUT.MAX_BUTTON_FONT_SIZE; + const iconSize = getScaledIconSize(Number(fontSize)); + + let scaledIcon; + + if (React.isValidElement(icon)) { + const iconElement = icon as React.ReactElement; + + scaledIcon = React.cloneElement( + iconElement as React.ReactElement, + { + ...(iconElement.props as Record), + size: iconSize, + width: iconSize, + height: iconSize, + } as any, + ); + } else { + scaledIcon = icon; + } + + return ( + + {scaledIcon} + + {text} + + + ); +}; + +export const FButton = ({ + text, + icon, + width, + last, + singleChild, + isVertical, + borderRadius = LAYOUT.PILL_BORDER_RADIUS, + fontSize = LAYOUT.MAX_BUTTON_FONT_SIZE, + buttonHeight = LAYOUT.BUTTON_HEIGHT, + testID, + ...props +}: FButtonProps) => { + const { colors } = useTheme(); + const scale = useRef(new Animated.Value(1)).current; + + const animateScaleTo = useCallback( + (toValue: number) => { + Animated.timing(scale, { + toValue, + duration: BUTTON_SCALE_ANIMATION_DURATION_MS, + useNativeDriver: true, + }).start(); + }, + [scale], + ); + + const customButtonStyles = useMemo(() => { + const baseStyles = { ...buttonContentStaticStyles.root }; + return { + root: { + ...baseStyles, + height: buttonHeight, + minHeight: buttonHeight, + backgroundColor: colors.buttonBackgroundColor, + }, + text: { + color: colors.buttonAlternativeTextColor, + fontSize, + }, + textDisabled: { + color: colors.formBorder, + }, + marginRight: buttonContentStaticStyles.marginRight, + marginBottom: buttonContentStaticStyles.marginBottom, + textBase: buttonContentStaticStyles.textBase, + }; + }, [colors, fontSize, buttonHeight]); + + const style: Record = {}; + const additionalStyles = !last ? (isVertical ? customButtonStyles.marginBottom : customButtonStyles.marginRight) : {}; + + if (width) { + style.paddingHorizontal = LAYOUT.PADDINGS; + if (singleChild && !isVertical) { + style.width = width * LAYOUT.SINGLE_BUTTON_WIDTH_FACTOR + LAYOUT.PADDINGS * 2; + } else { + style.width = isVertical ? '100%' : width + LAYOUT.PADDINGS * 2; + } + } + + const textStyle = [customButtonStyles.textBase, props.disabled ? customButtonStyles.textDisabled : customButtonStyles.text]; + + const handlePressIn = useCallback(() => { + if (props.disabled) return; + animateScaleTo(BUTTON_SCALE_PRESSED); + }, [animateScaleTo, props.disabled]); + + const handlePressOut = useCallback(() => { + animateScaleTo(1); + }, [animateScaleTo]); + + return ( + + + + + + ); +}; + +export const FContainer = forwardRef((props, ref) => { + const insets = useSafeAreaInsets(); + const { height, width, fontScale } = useWindowDimensions(); + const { sizeClass } = useSizeClass(); + const scaledButtonHeight = getScaledButtonHeight(fontScale); + + const childrenCount = React.Children.toArray(props.children).filter(Boolean).length; + + const initialLayoutWidth = useMemo(() => { + const drawerOffset = sizeClass === SizeClass.Large ? LAYOUT.DRAWER_WIDTH : 0; + return Math.max(0, Math.ceil(width - drawerOffset - LAYOUT.CONTAINER_SIDE_MARGIN * 2)); + }, [width, sizeClass]); + + const [layoutReady, setLayoutReady] = useState(() => initialLayoutWidth > 0); + const { calculateButtonWidth, calculateVisualParameters, calculateContainerHeight, buttonFontSize } = useFloatButtonLayout( + width, + sizeClass, + fontScale, + ); + + // Compute initial geometry up-front so the slide-in animation starts at the final (computed) size, + // avoiding a visible "big-to-small" jump during the entrance animation. + const initialGeometry = useMemo(() => { + if (initialLayoutWidth <= 0) { + return { + calculatedWidth: undefined as number | undefined, + shouldBeVertical: false, + buttonRadius: LAYOUT.PILL_BORDER_RADIUS, + }; + } + const calculatedWidth = calculateButtonWidth(initialLayoutWidth, childrenCount); + const { buttonRadius, shouldBeVertical } = calculateVisualParameters(calculatedWidth, childrenCount); + return { calculatedWidth, shouldBeVertical, buttonRadius }; + }, [initialLayoutWidth, calculateButtonWidth, calculateVisualParameters, childrenCount]); + + const [newWidth, setNewWidth] = useState(() => initialGeometry.calculatedWidth); + const [isVertical, setIsVertical] = useState(() => initialGeometry.shouldBeVertical); + const [buttonBorderRadius, setButtonBorderRadius] = useState(() => initialGeometry.buttonRadius); + + const latest = useRef({ newWidth, isVertical, buttonBorderRadius }); + latest.current = { newWidth, isVertical, buttonBorderRadius }; + + const layoutWidth = useRef(initialLayoutWidth); + // Avoid running the animation on the very first layout calculation. + // We already set initial geometry, so we can skip this first pass to prevent redundant state churn. + const isFirstLayoutCalculation = useRef(true); + + const bottomInsets = useMemo( + () => ({ + bottom: getFloatButtonBottomOffset(insets.bottom), + }), + [insets.bottom], + ); + + const { slideAnimation } = useFloatButtonAnimation(height); + + const handleBorderRadiusAnimation = useCallback((buttonRadius: number, shouldBeVertical: boolean, calculatedWidth: number) => { + setNewWidth(calculatedWidth); + setIsVertical(shouldBeVertical); + setButtonBorderRadius(buttonRadius); + }, []); + + const calculateLayout = useCallback(() => { + if (!layoutReady || layoutWidth.current <= 0) return; + + scheduleInNextFrame(() => { + const calculatedWidth = calculateButtonWidth(layoutWidth.current, childrenCount); + const { buttonRadius, shouldBeVertical } = calculateVisualParameters(calculatedWidth, childrenCount); + + if (isFirstLayoutCalculation.current) { + isFirstLayoutCalculation.current = false; + return; + } + + const prev = latest.current; + const widthDelta = Math.abs((prev.newWidth ?? 0) - calculatedWidth); + const buttonRadiusDelta = Math.abs(buttonRadius - prev.buttonBorderRadius); + + const widthEps = childrenCount === 1 ? 1 : 2; + const radiusEps = 0.5; + if (shouldBeVertical === prev.isVertical) { + if (widthDelta <= widthEps && buttonRadiusDelta <= radiusEps) return; + } + + if (shouldBeVertical !== prev.isVertical || widthDelta > widthEps) { + handleBorderRadiusAnimation(buttonRadius, shouldBeVertical, calculatedWidth); + } else { + setNewWidth(calculatedWidth); + setIsVertical(shouldBeVertical); + setButtonBorderRadius(buttonRadius); + } + }); + }, [ + layoutReady, + calculateButtonWidth, + calculateVisualParameters, + handleBorderRadiusAnimation, + childrenCount, + setNewWidth, + setIsVertical, + setButtonBorderRadius, + ]); + + const debouncedCalculateLayout = useMemo(() => debounce(calculateLayout, 16), [calculateLayout]); + + useEffect(() => { + debouncedCalculateLayout(); + }, [debouncedCalculateLayout, width, height, childrenCount, sizeClass, fontScale]); + + const onLayout = (event: { nativeEvent: { layout: { width: number } } }) => { + const { width: currentLayoutWidth } = event.nativeEvent.layout; + + if (currentLayoutWidth > 0) { + if (Math.abs(layoutWidth.current - currentLayoutWidth) > 2) { + layoutWidth.current = currentLayoutWidth; + } + + if (!layoutReady) { + setLayoutReady(true); + } + } + }; + + const renderChild = (child: ReactNode, index: number, array: ReactNode[]): ReactNode => { + if (typeof child === 'string') { + return ( + + + {child} + + + ); + } + + const isSingleChild = array.length === 1; + + return React.cloneElement(child as React.ReactElement, { + width: effectiveNewWidth, + key: index, + last: index === array.length - 1, + singleChild: isSingleChild, + isVertical, + borderRadius: buttonBorderRadius, + fontSize: buttonFontSize, + buttonHeight: scaledButtonHeight, + }); + }; + + const containerHeight = useMemo( + () => calculateContainerHeight(childrenCount, isVertical), + [calculateContainerHeight, childrenCount, isVertical], + ); + + const effectiveNewWidth = newWidth ?? layoutWidth.current; + + const combinedStyles = useMemo( + () => [ + containerStyles.root, + props.inline ? containerStyles.rootInline : containerStyles.rootAbsolute, + bottomInsets, + effectiveNewWidth ? (isVertical ? containerStyles.rootPostVertical : containerStyles.rootPost) : containerStyles.rootPre, + isVertical ? containerHeight : { minHeight: scaledButtonHeight }, + { transform: [{ translateY: slideAnimation }] }, + ], + [props.inline, bottomInsets, effectiveNewWidth, isVertical, containerHeight, slideAnimation, scaledButtonHeight], + ); + + return ( + + {layoutReady ? React.Children.toArray(props.children).filter(Boolean).map(renderChild) : props.children} + + ); +}); + +const BOTTOM_FADE_HEIGHT = 50; + +const bottomFadeStyles = StyleSheet.create({ + wrapper: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + }, +}); + +const BOTTOM_FADE_GRADIENT_START = { x: 0.5, y: 1 }; +const BOTTOM_FADE_GRADIENT_END = { x: 0.5, y: 0 }; + +export const FloatButtonsBottomFade = React.memo(() => { + const insets = useSafeAreaInsets(); + const { colors } = useTheme(); + + const heightStyle = useMemo(() => ({ height: BOTTOM_FADE_HEIGHT + insets.bottom }), [insets.bottom]); + const gradientColors = useMemo(() => [colors.background, withAlpha(colors.background, 0)], [colors.background]); + + return ( + + + + ); +}); diff --git a/components/HandOffComponent.ios.tsx b/components/HandOffComponent.ios.tsx new file mode 100644 index 00000000000..e0155319658 --- /dev/null +++ b/components/HandOffComponent.ios.tsx @@ -0,0 +1,47 @@ +import React from 'react'; +import DefaultPreference from 'react-native-default-preference'; +// @ts-ignore: Handoff is not typed +import Handoff from 'react-native-handoff'; +import { useSettings } from '../hooks/context/useSettings'; +import { GROUP_IO_BLUEWALLET } from '../blue_modules/currency'; +import { BlueApp } from '../class/blue-app'; +import { HandOffComponentProps } from './types'; + +const HandOffComponent: React.FC = props => { + const { isHandOffUseEnabled } = useSettings(); + if (!props || !props.type || !props.userInfo || Object.keys(props.userInfo).length === 0) { + console.debug('HandOffComponent: Missing required type or userInfo data'); + return null; + } + const userInfo = JSON.stringify(props.userInfo); + console.debug(`HandOffComponent is rendering. Type: ${props.type}, UserInfo: ${userInfo}...`); + return isHandOffUseEnabled ? : null; +}; + +const MemoizedHandOffComponent = React.memo(HandOffComponent); + +export const setIsHandOffUseEnabled = async (value: boolean) => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(BlueApp.HANDOFF_STORAGE_KEY, value.toString()); + console.debug('setIsHandOffUseEnabled', value); + } catch (error) { + console.error('Error setting handoff enabled status:', error); + throw error; // Propagate error to caller + } +}; + +export const getIsHandOffUseEnabled = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + const isEnabledValue = await DefaultPreference.get(BlueApp.HANDOFF_STORAGE_KEY); + const result = isEnabledValue === 'true'; + console.debug('getIsHandOffUseEnabled', result); + return result; + } catch (error) { + console.error('Error getting handoff enabled status:', error); + return false; + } +}; + +export default MemoizedHandOffComponent; diff --git a/components/HandOffComponent.tsx b/components/HandOffComponent.tsx new file mode 100644 index 00000000000..12787f7df99 --- /dev/null +++ b/components/HandOffComponent.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { HandOffComponentProps } from './types'; + +const HandOffComponent: React.FC = props => { + console.debug('HandOffComponent render.'); + return null; +}; + +export const setIsHandOffUseEnabled = async (value: boolean) => {}; + +export const getIsHandOffUseEnabled = async (): Promise => { + return false; +}; + +export default HandOffComponent; diff --git a/components/Header.tsx b/components/Header.tsx new file mode 100644 index 00000000000..eef8335e32f --- /dev/null +++ b/components/Header.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; +import { useTheme } from './themes'; +import AddWalletButton from './AddWalletButton'; + +interface HeaderProps { + leftText: string; + isDrawerList?: boolean; + onNewWalletPress?: () => void; +} + +export const Header: React.FC = ({ leftText, isDrawerList, onNewWalletPress }) => { + const { colors } = useTheme(); + const styleWithProps = StyleSheet.create({ + root: { + backgroundColor: isDrawerList ? colors.elevated : colors.background, + borderTopColor: isDrawerList ? colors.elevated : colors.background, + borderBottomColor: isDrawerList ? colors.elevated : colors.background, + }, + text: { + color: colors.foregroundColor, + }, + }); + + return ( + + {leftText} + {onNewWalletPress && } + + ); +}; + +const styles = StyleSheet.create({ + root: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + marginBottom: 8, + }, + text: { + textAlign: 'left', + fontWeight: 'bold', + fontSize: 34, + }, +}); diff --git a/components/HeaderMenuButton.tsx b/components/HeaderMenuButton.tsx new file mode 100644 index 00000000000..d46fe16e357 --- /dev/null +++ b/components/HeaderMenuButton.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Pressable, Platform, StyleSheet } from 'react-native'; +import ToolTipMenu from './TooltipMenu'; +import { useTheme } from './themes'; +import Icon from './Icon'; +import { Action } from './types'; + +interface HeaderMenuButtonProps { + onPressMenuItem: (id: string) => void; + actions?: Action[] | Action[][]; + disabled?: boolean; + title?: string; +} + +const HeaderMenuButton: React.FC = ({ onPressMenuItem, actions, disabled, title }) => { + const { colors } = useTheme(); + const styleProps = Platform.OS === 'android' ? { iconStyle: { transform: [{ rotate: '90deg' }] } } : {}; + + if (!actions || actions.length === 0) { + return ( + [styles.buttonCenter, pressed && styles.pressed]} + > + + + ); + } + + const menuActions = Array.isArray(actions[0]) ? (actions as Action[][]) : (actions as Action[]); + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + buttonCenter: { + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + minWidth: 44, + minHeight: 44, + }, + pressed: { opacity: 0.5 }, +}); + +export default HeaderMenuButton; diff --git a/components/HeaderRightButton.tsx b/components/HeaderRightButton.tsx new file mode 100644 index 00000000000..19f8a391e76 --- /dev/null +++ b/components/HeaderRightButton.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity } from 'react-native'; + +import { useTheme } from './themes'; + +interface HeaderRightButtonProps { + disabled: boolean; + onPress?: () => void; + title: string; + testID?: string; +} + +const HeaderRightButton: React.FC = ({ disabled, onPress, title, testID }) => { + const { colors } = useTheme(); + const opacity = disabled ? 0.5 : 1; + return ( + + {title} + + ); +}; + +const styles = StyleSheet.create({ + save: { + alignItems: 'center', + justifyContent: 'center', + alignSelf: 'center', + flexDirection: 'row', + minWidth: 80, + paddingHorizontal: 12, + borderRadius: 8, + height: 34, + }, + saveText: { + fontSize: 15, + fontWeight: '600', + }, +}); + +export default HeaderRightButton; diff --git a/components/HighlightedText.tsx b/components/HighlightedText.tsx new file mode 100644 index 00000000000..a9169aaa32f --- /dev/null +++ b/components/HighlightedText.tsx @@ -0,0 +1,196 @@ +import React, { useCallback, useMemo, useEffect, useState } from 'react'; +import { Text, Animated, StyleSheet, Platform, TextStyle } from 'react-native'; +import useBounceAnimation from '../hooks/useBounceAnimation'; + +interface HighlightedTextProps { + text: string; + query: string; + numberOfLines?: number; + style?: TextStyle | TextStyle[]; + highlightStyle?: TextStyle; + bounceAnim?: Animated.Value; + caseSensitive?: boolean; + highlightOnlyFirstMatch?: boolean; +} + +interface TextPart { + text: string; + isMatch: boolean; +} + +const HighlightedText: React.FC = ({ + text, + query, + numberOfLines, + style, + highlightStyle, + bounceAnim: externalBounceAnim, + caseSensitive = false, + highlightOnlyFirstMatch = false, +}) => { + const internalBounceAnim = useBounceAnimation(query); + const bounceAnim = externalBounceAnim || internalBounceAnim; + const [queryKey, setQueryKey] = useState(''); + + useEffect(() => { + setQueryKey(query); + }, [query]); + + const baseTextStyle = useMemo(() => { + if (!style) return {}; + + if (Array.isArray(style)) { + return style.reduce((acc, curr) => ({ ...acc, ...curr }), {}); + } + + return style; + }, [style]); + + const highlightedStyle = useMemo( + () => ({ + ...styles.highlight, + ...(highlightStyle || {}), + fontSize: baseTextStyle.fontSize, + fontFamily: baseTextStyle.fontFamily, + fontWeight: baseTextStyle.fontWeight || '600', + lineHeight: baseTextStyle.lineHeight, + letterSpacing: baseTextStyle.letterSpacing, + transform: Platform.OS === 'ios' ? [{ scale: bounceAnim }] : undefined, + }), + [bounceAnim, highlightStyle, baseTextStyle], + ); + + // Create a style for non-highlighted text parts that ensures it looks the same as original text + const nonHighlightedStyle = useMemo( + () => ({ + ...baseTextStyle, // Copy all original text properties + }), + [baseTextStyle], + ); + + const renderTextPart = useCallback( + (part: TextPart, index: number) => { + if (part.isMatch) { + return ( + + + {part.text} + + + ); + } + + return ( + + {part.text} + + ); + }, + [queryKey, highlightedStyle, bounceAnim, nonHighlightedStyle], + ); + + const textParts = useMemo((): TextPart[] => { + if (!query) { + return [{ text, isMatch: false }]; + } + + try { + const searchQueryText = caseSensitive ? query : query.toLowerCase(); + const processedText = caseSensitive ? text : text.toLowerCase(); + + if (searchQueryText.trim() === '') { + return [{ text, isMatch: false }]; + } + + const parts: TextPart[] = []; + let lastIndex = 0; + let searchStartIndex = 0; + + while (true) { + const matchIndex = processedText.indexOf(searchQueryText, searchStartIndex); + + if (matchIndex === -1) { + break; + } + + if (matchIndex > lastIndex) { + parts.push({ + text: text.substring(lastIndex, matchIndex), + isMatch: false, + }); + } + + parts.push({ + text: text.substring(matchIndex, matchIndex + searchQueryText.length), + isMatch: true, + }); + + lastIndex = matchIndex + searchQueryText.length; + searchStartIndex = lastIndex; + + if (highlightOnlyFirstMatch) { + break; + } + } + + if (lastIndex < text.length) { + parts.push({ + text: text.substring(lastIndex), + isMatch: false, + }); + } + + return parts.length > 0 ? parts : [{ text, isMatch: false }]; + } catch (e) { + return [{ text, isMatch: false }]; + } + }, [text, query, caseSensitive, highlightOnlyFirstMatch]); + + if (textParts.length === 1 && !textParts[0].isMatch) { + return ( + + {text} + + ); + } + + return ( + + {textParts.map(renderTextPart)} + + ); +}; + +const styles = StyleSheet.create({ + text: { + fontSize: 16, + }, + highlightContainer: { + overflow: 'hidden', + margin: 0, + padding: 0, + }, + highlight: { + fontWeight: '600', + borderRadius: 4, + borderWidth: 1, + paddingHorizontal: 3, + paddingVertical: 1, + marginHorizontal: 1, + overflow: 'hidden', + textDecorationLine: Platform.OS === 'android' ? 'underline' : 'none', + backgroundColor: '#FFF5C0', + color: '#000000', + borderColor: 'rgba(255, 255, 255, 0.5)', + shadowColor: '#000000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 1, + elevation: 2, + }, +}); + +export default HighlightedText; diff --git a/components/Icon.tsx b/components/Icon.tsx new file mode 100644 index 00000000000..f3da932e4f2 --- /dev/null +++ b/components/Icon.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Pressable, StyleProp, TextStyle, View, ViewStyle } from 'react-native'; +import Entypo from '@react-native-vector-icons/entypo'; +import FontAwesome from '@react-native-vector-icons/fontawesome'; +import FontAwesome6 from '@react-native-vector-icons/fontawesome6'; +import Ionicons from '@react-native-vector-icons/ionicons'; +import MaterialDesignIcons from '@react-native-vector-icons/material-design-icons'; +import MaterialIcons from '@react-native-vector-icons/material-icons'; + +export type FontAwesomeIconName = React.ComponentProps['name']; +export type FontAwesome6IconName = React.ComponentProps['name']; +export type IonIconName = React.ComponentProps['name']; +export type MaterialIconName = React.ComponentProps['name']; +export type MaterialDesignIconName = React.ComponentProps['name']; +export type EntypoIconName = React.ComponentProps['name']; + +type IconType = 'font-awesome' | 'font-awesome-6' | 'ionicons' | 'material' | 'material-community' | 'entypo'; +type IconComponentType = React.ComponentType; + +type IconNameFor = T extends 'font-awesome' + ? FontAwesomeIconName + : T extends 'font-awesome-6' + ? FontAwesome6IconName + : T extends 'ionicons' + ? IonIconName + : T extends 'material' + ? MaterialIconName + : T extends 'material-community' + ? MaterialDesignIconName + : T extends 'entypo' + ? EntypoIconName + : never; + +export interface IconProps { + name: IconNameFor; + type?: T; + /** + * @default 24 + */ + size?: number; + color?: string; + style?: StyleProp; + iconStyle?: T extends 'font-awesome-6' ? 'solid' | 'brand' | 'regular' : StyleProp; + containerStyle?: StyleProp; + onPress?: () => void; + accessibilityLabel?: string; + testID?: string; +} + +const ICON_COMPONENTS: Record = { + 'font-awesome': FontAwesome, + 'font-awesome-6': FontAwesome6, + ionicons: Ionicons, + material: MaterialIcons, + 'material-community': MaterialDesignIcons, + entypo: Entypo, +}; + +const Icon = ({ + name, + type, + size = 24, + color, + style, + iconStyle, + containerStyle, + onPress, + accessibilityLabel, + testID, +}: IconProps): React.ReactElement | null => { + const IconComponent = ICON_COMPONENTS[type ?? 'font-awesome'] as React.ComponentType; + const isFa6 = type === 'font-awesome-6'; + const fa6IconStyle = isFa6 ? (typeof iconStyle === 'string' ? iconStyle : 'solid') : undefined; + const mergedStyle = isFa6 ? style : [style, iconStyle]; + + const content = ( + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + if (containerStyle) { + return {content}; + } + + return content; +}; + +export default Icon; diff --git a/components/InputAccessoryAllFunds.js b/components/InputAccessoryAllFunds.js deleted file mode 100644 index 4f7ec698fc1..00000000000 --- a/components/InputAccessoryAllFunds.js +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Text } from 'react-native-elements'; -import { InputAccessoryView, StyleSheet, Keyboard, Platform, View } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -import loc from '../loc'; -import { BitcoinUnit } from '../models/bitcoinUnits'; -import { BlueButtonLink } from '../BlueComponents'; - -const InputAccessoryAllFunds = ({ balance, canUseAll, onUseAllPressed }) => { - const { colors } = useTheme(); - - const stylesHook = StyleSheet.create({ - root: { - backgroundColor: colors.inputBackgroundColor, - }, - totalLabel: { - color: colors.alternativeTextColor, - }, - totalCanNot: { - color: colors.alternativeTextColor, - }, - }); - - const inputView = ( - - - {loc.send.input_total} - {canUseAll ? ( - - ) : ( - - {balance} {BitcoinUnit.BTC} - - )} - - - - - - ); - - if (Platform.OS === 'ios') { - return {inputView}; - } - - // androidPlaceholder View is needed to force shrink screen (KeyboardAvoidingView) where this component is used - return ( - <> - - {inputView} - - ); -}; - -InputAccessoryAllFunds.InputAccessoryViewID = 'useMaxInputAccessoryViewID'; - -InputAccessoryAllFunds.propTypes = { - balance: PropTypes.string.isRequired, - canUseAll: PropTypes.bool.isRequired, - onUseAllPressed: PropTypes.func.isRequired, -}; - -const styles = StyleSheet.create({ - root: { - flex: 1, - flexDirection: 'row', - maxHeight: 44, - justifyContent: 'space-between', - alignItems: 'center', - }, - left: { - flexDirection: 'row', - justifyContent: 'flex-start', - alignItems: 'flex-start', - }, - totalLabel: { - fontSize: 16, - marginLeft: 8, - marginRight: 0, - paddingRight: 0, - paddingLeft: 0, - paddingTop: 12, - paddingBottom: 12, - }, - totalCan: { - marginLeft: 8, - paddingRight: 0, - paddingLeft: 0, - paddingTop: 12, - paddingBottom: 12, - }, - totalCanNot: { - fontSize: 16, - marginLeft: 8, - marginRight: 0, - paddingRight: 0, - paddingLeft: 0, - paddingTop: 12, - paddingBottom: 12, - }, - right: { - flexDirection: 'row', - justifyContent: 'flex-end', - alignItems: 'flex-end', - }, - done: { - paddingRight: 8, - paddingLeft: 0, - paddingTop: 12, - paddingBottom: 12, - }, - androidPlaceholder: { - height: 44, - }, - androidAbsolute: { - height: 44, - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - }, -}); - -export default InputAccessoryAllFunds; diff --git a/components/InputAccessoryAllFunds.tsx b/components/InputAccessoryAllFunds.tsx new file mode 100644 index 00000000000..0d9acfd1f21 --- /dev/null +++ b/components/InputAccessoryAllFunds.tsx @@ -0,0 +1,123 @@ +import React from 'react'; +import { InputAccessoryView, Keyboard, Platform, StyleSheet, Text, View } from 'react-native'; +import BlueButtonLink from './BlueButtonLink'; +import loc from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import { useTheme } from './themes'; + +interface InputAccessoryAllFundsProps { + balance: string; + canUseAll: boolean; + onUseAllPressed: () => void; +} + +const InputAccessoryAllFunds: React.FC = ({ balance, canUseAll, onUseAllPressed }) => { + const { colors } = useTheme(); + + const stylesHook = StyleSheet.create({ + root: { + backgroundColor: colors.inputBackgroundColor, + }, + totalLabel: { + color: colors.alternativeTextColor, + }, + totalCanNot: { + color: colors.alternativeTextColor, + }, + }); + + const inputView = ( + + + {loc.send.input_total} + {canUseAll ? ( + + ) : ( + + {balance} {BitcoinUnit.BTC} + + )} + + + + + + ); + + if (Platform.OS === 'ios') { + return {inputView}; + } + + // androidPlaceholder View is needed to force shrink screen (KeyboardAvoidingView) where this component is used + return ( + <> + + {inputView} + + ); +}; + +export const InputAccessoryAllFundsAccessoryViewID = 'useMaxInputAccessoryViewID'; + +const styles = StyleSheet.create({ + root: { + flex: 1, + flexDirection: 'row', + maxHeight: 44, + justifyContent: 'space-between', + alignItems: 'center', + }, + left: { + flexDirection: 'row', + justifyContent: 'flex-start', + alignItems: 'flex-start', + }, + totalLabel: { + fontSize: 16, + marginLeft: 8, + marginRight: 0, + paddingRight: 0, + paddingLeft: 0, + paddingTop: 12, + paddingBottom: 12, + }, + totalCan: { + marginLeft: 8, + paddingRight: 0, + paddingLeft: 0, + paddingTop: 12, + paddingBottom: 12, + }, + totalCanNot: { + fontSize: 16, + marginLeft: 8, + marginRight: 0, + paddingRight: 0, + paddingLeft: 0, + paddingTop: 12, + paddingBottom: 12, + }, + right: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'flex-end', + }, + done: { + paddingRight: 8, + paddingLeft: 0, + paddingTop: 12, + paddingBottom: 12, + }, + androidPlaceholder: { + height: 44, + }, + androidAbsolute: { + height: 44, + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + }, +}); + +export default InputAccessoryAllFunds; diff --git a/components/LNNodeBar.js b/components/LNNodeBar.js deleted file mode 100644 index 9fb7b97a829..00000000000 --- a/components/LNNodeBar.js +++ /dev/null @@ -1,88 +0,0 @@ -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import loc, { formatBalanceWithoutSuffix } from '../loc'; -import PropTypes from 'prop-types'; -import { BitcoinUnit } from '../models/bitcoinUnits'; -import { useTheme } from '@react-navigation/native'; - -export const LNNodeBar = props => { - const { canReceive = 0, canSend = 0, nodeAlias = '', disabled = false, itemPriceUnit = BitcoinUnit.SATS } = props; - const { colors } = useTheme(); - const opacity = { opacity: disabled ? 0.5 : 1.0 }; - const canSendBarFlex = { - flex: canReceive > 0 && canSend > 0 ? Math.abs(canSend / (canReceive + canSend)) * 1.0 : 1.0, - }; - const stylesHook = StyleSheet.create({ - nodeAlias: { - color: colors.alternativeTextColor2, - }, - }); - return ( - - {nodeAlias.trim().length > 0 && {nodeAlias}} - - - - - - - - - {loc.lnd.can_send.toUpperCase()} - {formatBalanceWithoutSuffix(canSend, itemPriceUnit, true).toString()} - - - {loc.lnd.can_receive.toUpperCase()} - {formatBalanceWithoutSuffix(canReceive, itemPriceUnit, true).toString()} - - - - ); -}; - -export default LNNodeBar; - -LNNodeBar.propTypes = { - canReceive: PropTypes.number.isRequired, - canSend: PropTypes.number.isRequired, - nodeAlias: PropTypes.string, - disabled: PropTypes.bool, - itemPriceUnit: PropTypes.string, -}; -const styles = StyleSheet.create({ - root: { - flex: 1, - }, - containerBottomText: { - flexDirection: 'row', - justifyContent: 'space-between', - marginTop: 16, - }, - nodeAlias: { - marginVertical: 16, - }, - canSendBar: { - height: 14, - maxHeight: 14, - backgroundColor: '#4E6CF5', - borderRadius: 6, - }, - canReceiveBar: { backgroundColor: '#57B996', borderRadius: 6, height: 14, maxHeight: 14 }, - fullFlexDirectionRow: { - flexDirection: 'row', - flex: 1, - }, - containerBottomLeftText: {}, - containerBottomRightText: {}, - titleText: { - color: '#9AA0AA', - }, - canReceive: { - color: '#57B996', - textAlign: 'right', - }, - canSend: { - color: '#4E6CF5', - textAlign: 'left', - }, -}); diff --git a/components/LdkButton.js b/components/LdkButton.js deleted file mode 100644 index f040a227cff..00000000000 --- a/components/LdkButton.js +++ /dev/null @@ -1,36 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import { useTheme } from '@react-navigation/native'; -import { Image, TouchableOpacity, View } from 'react-native'; -import { Text } from 'react-native-elements'; -import React from 'react'; - -export const LdkButton = props => { - const { colors } = useTheme(); - return ( - - - - - - - - {props.text || '?'} - {props.subtext || '?'} - - - - - ); -}; diff --git a/components/ListItem.tsx b/components/ListItem.tsx new file mode 100644 index 00000000000..0d727d3b579 --- /dev/null +++ b/components/ListItem.tsx @@ -0,0 +1,260 @@ +import React, { useMemo } from 'react'; +import { + ActivityIndicator, + Pressable, + StyleProp, + StyleSheet, + Switch, + SwitchProps, + Text, + TextStyle, + useWindowDimensions, + View, + ViewStyle, +} from 'react-native'; +import { useLocale } from '@react-navigation/native'; + +import Icon from './Icon'; +import { useTheme } from './themes'; + +/** Base row height for transaction list `getItemLayout` (padding + title + subtitle at fontScale 1). */ +export const TX_ROW_BASE_HEIGHT = 64; + +export interface ListItemProps { + leftAvatar?: React.JSX.Element; + containerStyle?: StyleProp; + noFeedback?: boolean; + bottomDivider?: boolean; + testID?: string; + switchTestID?: string; + onPress?: () => void; + disabled?: boolean; + switch?: SwitchProps; + title: string; + titleStyle?: StyleProp; + subtitle?: string | React.ReactNode; + subtitleNumberOfLines?: number; + rightTitle?: string; + rightTitleStyle?: StyleProp; + rightTitleSelectable?: boolean; + rightSubtitle?: string | React.ReactNode; + rightSubtitleStyle?: StyleProp; + chevron?: boolean; + checkmark?: boolean; + isLoading?: boolean; +} + +const ListItem: React.FC = React.memo( + ({ + leftAvatar, + containerStyle, + noFeedback = false, + bottomDivider = true, + testID, + switchTestID, + onPress, + disabled, + switch: switchProps, + title, + titleStyle, + subtitle, + subtitleNumberOfLines, + rightTitle, + rightTitleStyle, + rightTitleSelectable, + rightSubtitle, + rightSubtitleStyle, + chevron, + checkmark, + isLoading, + }: ListItemProps) => { + const { colors } = useTheme(); + const { direction } = useLocale(); + const { fontScale } = useWindowDimensions(); + const isRtl = direction === 'rtl'; + const contentRowStyle = useMemo( + () => ({ + paddingVertical: Math.round(12 * fontScale), + }), + [fontScale], + ); + const stylesHook = StyleSheet.create({ + title: { + color: disabled ? colors.buttonDisabledTextColor : colors.foregroundColor, + fontSize: 16, + fontWeight: '500', + lineHeight: Math.round(22 * fontScale), + writingDirection: direction, + }, + rightMemoText: { + textAlign: direction === 'rtl' ? 'left' : 'right', + }, + subtitle: { + flexWrap: 'wrap', + writingDirection: direction, + color: colors.alternativeTextColor, + fontWeight: '400', + paddingVertical: switchProps ? 8 : 0, + lineHeight: Math.round(20 * fontScale), + fontSize: 14, + marginTop: 2, + }, + + containerStyle: { + backgroundColor: colors.background, + }, + divider: { + borderBottomWidth: bottomDivider ? StyleSheet.hairlineWidth : 0, + borderBottomColor: colors.formBorder, + }, + }); + + const memoizedSwitchProps = useMemo(() => { + return switchProps ? { ...switchProps } : undefined; + }, [switchProps]); + const resolvedSwitchTestID = switchTestID ?? memoizedSwitchProps?.testID; + const enableFeedback = !noFeedback && !!onPress && !disabled; + + const renderContent = () => ( + + {leftAvatar && ( + + {leftAvatar} + + + )} + + + {title} + + {subtitle ? ( + + {subtitle} + + ) : null} + + + {rightTitle || rightSubtitle ? ( + + {rightTitle ? ( + + {rightTitle} + + ) : null} + {rightSubtitle != null && rightSubtitle !== '' ? ( + + + {rightSubtitle} + + + ) : null} + + ) : null} + {isLoading ? ( + + ) : ( + <> + {chevron ? ( + + ) : null} + {switchProps ? ( + + ) : null} + {checkmark ? ( + + + + ) : null} + + )} + + ); + + if (!onPress) { + return ( + + {renderContent()} + + ); + } + + return ( + [ + stylesHook.containerStyle, + stylesHook.divider, + containerStyle, + disabled && styles.disabled, + enableFeedback && pressed && styles.pressed, + ]} + > + {renderContent()} + + ); + }, +); + +export default ListItem; + +const styles = StyleSheet.create({ + margin16: { + marginLeft: 16, + }, + width16: { width: 16 }, + contentRow: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + }, + content: { + flex: 1, + flexShrink: 1, + minWidth: 0, + justifyContent: 'center', + }, + leftAvatarContainer: { + flexDirection: 'row', + alignItems: 'center', + alignSelf: 'center', + }, + rightColumn: { + marginStart: 8, + flexShrink: 0, + alignItems: 'flex-end', + alignSelf: 'center', + }, + rightMemoWrapper: { + flexShrink: 1, + minWidth: 0, + }, + checkmarkContainer: { + marginLeft: 8, + }, + disabled: { + opacity: 0.6, + }, + pressed: { + opacity: 0.6, + }, +}); diff --git a/components/ManageWalletsListItem.tsx b/components/ManageWalletsListItem.tsx new file mode 100644 index 00000000000..65947507507 --- /dev/null +++ b/components/ManageWalletsListItem.tsx @@ -0,0 +1,439 @@ +import React, { useCallback, useState, useEffect, useRef } from 'react'; +import { StyleSheet, ViewStyle, ActivityIndicator, Platform, Animated, View, Text, Pressable } from 'react-native'; +import { useLocale } from '@react-navigation/native'; +import { Swipeable } from 'react-native-gesture-handler'; +import { ExtendedTransaction, LightningTransaction, Transaction, TWallet } from '../class/wallets/types'; +import loc from '../loc'; +import { TransactionListItem } from './TransactionListItem'; +import { useTheme } from './themes'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import { AddressItem } from './addresses/AddressItem'; +import { ItemType, AddressItemData } from '../models/itemTypes'; +import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet'; +import { AbstractHDElectrumWallet } from '../class/wallets/abstract-hd-electrum-wallet'; +import { WatchOnlyWallet } from '../class/wallets/watch-only-wallet'; +import WalletListItem from './WalletListItem'; + +const getHdElectrumWallet = (wallet: TWallet): AbstractHDElectrumWallet | undefined => { + const w: unknown = wallet; + if (w instanceof AbstractHDElectrumWallet) return w; + if (w instanceof WatchOnlyWallet) { + const inner: unknown = w._hdWalletInstance; + if (inner instanceof AbstractHDElectrumWallet) return inner; + } + return undefined; +}; + +const getWalletIconImage = (walletType: string, direction: string) => { + switch (walletType) { + case LightningCustodianWallet.type: + case LightningArkWallet.type: + return direction === 'rtl' ? require('../img/lnd-shape-rtl.png') : require('../img/lnd-shape.png'); + case MultisigHDWallet.type: + return direction === 'rtl' ? require('../img/vault-shape-rtl.png') : require('../img/vault-shape.png'); + default: + return direction === 'rtl' ? require('../img/btc-shape-rtl.png') : require('../img/btc-shape.png'); + } +}; + +interface WalletItem { + type: ItemType.WalletSection; + data: TWallet; +} + +interface TransactionItem { + type: ItemType.TransactionSection; + data: ExtendedTransaction & LightningTransaction; +} + +interface AddressItem { + type: ItemType.AddressSection; + data: AddressItemData; +} + +type Item = WalletItem | TransactionItem | AddressItem; + +interface ManageWalletsListItemProps { + item: Item; + isDraggingDisabled: boolean; + handleToggleHideBalance: (wallet: TWallet) => void; + state: { wallets: TWallet[]; searchQuery: string; isSearchFocused?: boolean }; + navigateToWallet: (wallet: TWallet) => void; + navigateToAddress: (address: string, walletID: string) => void; + renderHighlightedText: (text: string, query: string) => React.ReactElement; + isActive: boolean; + globalDragActive: boolean; + drag?: () => void; + isPlaceHolder?: boolean; + onPressIn?: () => void; + onPressOut?: () => void; + style?: ViewStyle; +} + +const ManageWalletsListItem: React.FC = ({ + item, + isDraggingDisabled, + drag, + state, + isPlaceHolder = false, + navigateToWallet, + navigateToAddress, + renderHighlightedText, + onPressIn, + onPressOut, + handleToggleHideBalance, + isActive, + globalDragActive, + style, +}) => { + const { colors, dark } = useTheme(); + const { direction } = useLocale(); + const [isLoading, setIsLoading] = useState(false); + + const prevIsActive = useRef(isActive); + const swipeableRef = useRef(null); + const swipeInProgressRef = useRef(false); + + useEffect(() => { + if (isActive !== prevIsActive.current) { + triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium); + } + prevIsActive.current = isActive; + }, [isActive]); + + const onPress = useCallback(() => { + if (swipeInProgressRef.current) return; + if (item.type === ItemType.WalletSection) { + setIsLoading(true); + navigateToWallet(item.data); + setIsLoading(false); + } else if (item.type === ItemType.AddressSection) { + navigateToAddress(item.data.address, item.data.walletID); + } + }, [item, navigateToWallet, navigateToAddress]); + + const startDrag = useCallback(() => { + if (swipeInProgressRef.current) { + swipeableRef.current?.close?.(); + return; + } + triggerHapticFeedback(HapticFeedbackTypes.ImpactMedium); + if (drag) { + drag(); + } + }, [drag]); + + if (isLoading) { + return ; + } + + if (item.type === ItemType.WalletSection) { + const wallet = item.data; + const titleColor = dark ? colors.foregroundColor : colors.darkGray; + const iconImage = getWalletIconImage(wallet.type, direction); + + const canSwipe = !isActive && !globalDragActive; + const isHidden = !!wallet.hideBalance; + + const onToggle = () => { + handleToggleHideBalance(wallet); + swipeableRef.current?.close?.(); + }; + + const renderRightActions = () => ( + + [ + styles.rightAction, + { backgroundColor: colors.buttonBackgroundColor }, + pressed && styles.rightActionPressed, + ]} + onPress={onToggle} + accessibilityRole="button" + testID={isHidden ? 'SwipeShowBalance' : 'SwipeHideBalance'} + > + + {isHidden ? loc.wallets.swipe_balance_show : loc.wallets.swipe_balance_hide} + + + + ); + + const content = ( + + ); + + if (!canSwipe) return content; + + return ( + { + swipeableRef.current = r; + }} + onSwipeableWillOpen={() => { + swipeInProgressRef.current = true; + }} + onSwipeableWillClose={() => { + swipeInProgressRef.current = false; + }} + onSwipeableClose={() => { + swipeInProgressRef.current = false; + }} + renderRightActions={renderRightActions} + friction={2} + rightThreshold={40} + overshootRight={false} + > + {content} + + ); + } else if (item.type === ItemType.TransactionSection && item.data) { + try { + const w = state.wallets.find(wallet => wallet.getTransactions()?.some((tx: Transaction) => tx.hash === item.data.hash)); + + const walletID = w ? w.getID() : ''; + + const transactionStyle = { + borderLeftWidth: 2, + borderLeftColor: colors.brandingColor, + backgroundColor: colors.background, + background: colors.background, + }; + + return ( + + ); + } catch (e) { + console.warn('Error rendering transaction item:', e); + return null; + } + } else if (item.type === ItemType.AddressSection) { + const wallet = state.wallets.find(w => w.getID() === item.data.walletID); + if (!wallet) return null; + + const addressItemProps = { + item: { + key: item.data.address, + index: item.data.index, + address: item.data.address, + isInternal: item.data.isInternal, + balance: 0, + transactions: 0, + }, + balanceUnit: wallet.getPreferredBalanceUnit() || BitcoinUnit.BTC, + walletID: item.data.walletID, + allowSignVerifyMessage: wallet.allowSignVerifyMessage(), + onPress: () => navigateToAddress(item.data.address, item.data.walletID), + searchQuery: state.searchQuery, + renderHighlightedText, + }; + + return ; + } + + return null; +}; + +// WalletGroupItem component to handle displaying wallet and related search results +interface WalletGroupProps { + wallet: TWallet; + transactions: TransactionItem[]; + addresses: AddressItem[]; + state: { wallets: TWallet[]; searchQuery: string }; + navigateToWallet: (wallet: TWallet) => void; + navigateToAddress: (address: string, walletID: string) => void; + renderHighlightedText: (text: string, query: string) => React.ReactElement; +} + +const WalletGroupComponent: React.FC = ({ + wallet, + transactions, + addresses, + state, + navigateToWallet, + navigateToAddress, + renderHighlightedText, +}) => { + const { colors, dark } = useTheme(); + const { direction } = useLocale(); + const [expanded] = useState(true); + const fadeAnim = useRef(new Animated.Value(0)).current; + const hdElectrum = getHdElectrumWallet(wallet); + + useEffect(() => { + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + useNativeDriver: true, + }).start(); + }, [fadeAnim]); + + const cardRadius = 16; + const cardShadowStyle: ViewStyle = { + marginHorizontal: 16, + marginVertical: 10, + borderRadius: cardRadius, + ...Platform.select({ + ios: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + shadowRadius: 4, + }, + android: { + elevation: 2, + }, + }), + }; + + const cardBorderColor = dark ? colors.lightBorder : colors.borderTopColor; + + const cardInnerStyle: ViewStyle = { + borderRadius: cardRadius, + overflow: 'hidden' as const, + backgroundColor: colors.elevated, + borderWidth: StyleSheet.hairlineWidth, + borderColor: cardBorderColor, + }; + + const childItemsContainerStyle = { + backgroundColor: colors.elevated, + }; + + const childItemStyle = (): ViewStyle => ({ + backgroundColor: colors.elevated, + }); + + const walletHeaderBackgroundColor = dark ? colors.elevated : '#F9F9F9'; + + const dividerStyle = [styles.itemDivider, { backgroundColor: cardBorderColor }]; + + const onWalletPress = useCallback(() => { + navigateToWallet(wallet); + }, [navigateToWallet, wallet]); + + const titleColor = dark ? colors.foregroundColor : colors.darkGray; + const iconImage = getWalletIconImage(wallet.type, direction); + + const renderAddress = (address: AddressItem, index: number) => { + const computedBalance = hdElectrum?.getBalanceForExternalIndex(address.data.index) ?? 0; + const computedTransactions = hdElectrum?.getTransactionCountForExternalIndex(address.data.index) ?? 0; + + return ( + + + navigateToAddress(address.data.address, address.data.walletID)} + searchQuery={state.searchQuery} + renderHighlightedText={renderHighlightedText} + /> + + {index < addresses.length - 1 && } + + ); + }; + + return ( + + + + + + {expanded && ( + + {transactions.length > 0 && ( + <> + {transactions.map((transaction, index) => ( + + + + + {index < transactions.length - 1 && } + + ))} + + )} + + {addresses.length > 0 && <>{addresses.map(renderAddress)}} + + )} + + + + ); +}; + +const styles = StyleSheet.create({ + itemDivider: { + height: 1, + width: '100%', + }, + rightActionsContainer: { + justifyContent: 'center', + alignItems: 'flex-end', + }, + rightAction: { + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 18, + height: '100%', + }, + rightActionPressed: { + opacity: 0.85, + }, + rightActionText: { + fontSize: 15, + fontWeight: '600', + }, +}); + +export { WalletGroupComponent }; +export default ManageWalletsListItem; diff --git a/components/MultipleStepsListItem.js b/components/MultipleStepsListItem.js deleted file mode 100644 index 9fde9226eae..00000000000 --- a/components/MultipleStepsListItem.js +++ /dev/null @@ -1,243 +0,0 @@ -import React from 'react'; -import { useTheme } from '@react-navigation/native'; -import { View, StyleSheet, Text, TouchableOpacity, ActivityIndicator } from 'react-native'; -import PropTypes from 'prop-types'; -import { Icon } from 'react-native-elements'; -export const MultipleStepsListItemDashType = Object.freeze({ none: 0, top: 1, bottom: 2, topAndBottom: 3 }); -export const MultipleStepsListItemButtohType = Object.freeze({ partial: 0, full: 1 }); - -const MultipleStepsListItem = props => { - const { colors } = useTheme(); - const { - showActivityIndicator = false, - dashes = MultipleStepsListItemDashType.none, - circledText = '', - leftText = '', - checked = false, - } = props; - const stylesHook = StyleSheet.create({ - provideKeyButton: { - backgroundColor: colors.buttonDisabledBackgroundColor, - }, - provideKeyButtonText: { - color: colors.buttonTextColor, - }, - vaultKeyCircle: { - backgroundColor: colors.buttonDisabledBackgroundColor, - }, - vaultKeyText: { - color: colors.alternativeTextColor, - }, - vaultKeyCircleSuccess: { - backgroundColor: colors.msSuccessBG, - }, - rowPartialLeftText: { - color: colors.alternativeTextColor, - }, - }); - - const renderDashes = () => { - switch (dashes) { - case MultipleStepsListItemDashType.topAndBottom: - return { - width: 1, - borderStyle: 'dashed', - borderWidth: 0.8, - borderColor: '#c4c4c4', - top: 0, - bottom: 0, - marginLeft: 20, - position: 'absolute', - }; - case MultipleStepsListItemDashType.bottom: - return { - width: 1, - borderStyle: 'dashed', - borderWidth: 0.8, - borderColor: '#c4c4c4', - top: '50%', - bottom: 0, - marginLeft: 20, - position: 'absolute', - }; - case MultipleStepsListItemDashType.top: - return { - width: 1, - borderStyle: 'dashed', - borderWidth: 0.8, - borderColor: '#c4c4c4', - top: 0, - bottom: '50%', - marginLeft: 20, - position: 'absolute', - }; - default: - return {}; - } - }; - const buttonOpacity = { opacity: props.button?.disabled ? 0.5 : 1.0 }; - const rightButtonOpacity = { opacity: props.rightButton?.disabled ? 0.5 : 1.0 }; - return ( - - - - - {checked ? ( - - - - ) : circledText.length > 0 ? ( - - - {circledText} - - - ) : null} - {!showActivityIndicator && leftText.length > 0 && ( - - {leftText} - - )} - {showActivityIndicator && } - - {!showActivityIndicator && props.button && ( - <> - {props.button.buttonType === undefined || - (props.button.buttonType === MultipleStepsListItemButtohType.full && ( - - {props.button.text} - - ))} - {props.button.buttonType === MultipleStepsListItemButtohType.partial && ( - - - {props.button.leftText} - - - - {props.button.text} - - - - )} - - )} - {!showActivityIndicator && props.rightButton && checked && ( - - - {props.rightButton.text} - - - )} - - - ); -}; - -MultipleStepsListItem.propTypes = { - circledText: PropTypes.string, - checked: PropTypes.bool, - leftText: PropTypes.string, - showActivityIndicator: PropTypes.bool, - dashes: PropTypes.number, - button: PropTypes.shape({ - text: PropTypes.string, - onPress: PropTypes.func, - disabled: PropTypes.bool, - buttonType: PropTypes.number, - leftText: PropTypes.string, - }), - rightButton: PropTypes.shape({ - text: PropTypes.string, - onPress: PropTypes.func, - disabled: PropTypes.bool, - }), -}; - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - marginBottom: 16, - flex: 1, - justifyContent: 'space-between', - }, - buttonPartialContainer: { - borderRadius: 8, - borderColor: '#EEF0F4', - borderWidth: 1, - height: 48, - flex: 1, - justifyContent: 'space-between', - flexDirection: 'row', - alignItems: 'center', - paddingLeft: 16, - paddingRight: 8, - paddingVertical: 5, - marginLeft: 40, - }, - rowPartialRightButton: { - height: 36, - borderRadius: 8, - alignSelf: 'flex-end', - minWidth: 64, - justifyContent: 'center', - }, - itemKeyUnprovidedWrapper: { flexDirection: 'row' }, - vaultKeyCircle: { - width: 42, - height: 42, - borderRadius: 25, - justifyContent: 'center', - alignItems: 'center', - alignSelf: 'center', - }, - vaultKeyText: { fontSize: 18, fontWeight: 'bold' }, - vaultKeyTextWrapper: { justifyContent: 'center', alignContent: 'flex-start', paddingLeft: 16 }, - provideKeyButton: { - marginLeft: 40, - height: 48, - borderRadius: 8, - flex: 1, - justifyContent: 'center', - paddingHorizontal: 16, - }, - rightButton: { - borderRadius: 8, - textAlign: 'center', - }, - rightButtonContainer: { - alignContent: 'center', - justifyContent: 'center', - }, - activityIndicator: { - marginLeft: 40, - }, - provideKeyButtonText: { fontWeight: '600', fontSize: 15 }, - vaultKeyCircleSuccess: { - width: 42, - height: 42, - borderRadius: 25, - justifyContent: 'center', - alignItems: 'center', - }, - rowPartialLeftText: { - textAlign: 'center', - }, -}); - -export default MultipleStepsListItem; diff --git a/components/MultipleStepsListItem.tsx b/components/MultipleStepsListItem.tsx new file mode 100644 index 00000000000..e99d8581d88 --- /dev/null +++ b/components/MultipleStepsListItem.tsx @@ -0,0 +1,322 @@ +import React, { useRef } from 'react'; +import { + ActivityIndicator, + findNodeHandle, + GestureResponderEvent, + Platform, + StyleProp, + StyleSheet, + Text, + Pressable, + View, + ViewStyle, +} from 'react-native'; +import Icon from './Icon'; +import ActionSheet from '../screen/ActionSheet'; +import { useTheme } from './themes'; +import { ActionSheetOptions } from '../screen/ActionSheet.common'; + +export enum MultipleStepsListItemDashType { + None = 0, + Top = 1, + Bottom = 2, + TopAndBottom = 3, +} + +export enum MultipleStepsListItemButtonType { + Partial = 0, + Full = 1, +} + +interface MultipleStepsListItemProps { + circledText?: string; + checked?: boolean; + leftText?: string; + showActivityIndicator?: boolean; + isActionSheet?: boolean; + actionSheetOptions?: ActionSheetOptions; + dashes?: MultipleStepsListItemDashType; + button?: { + text?: string; + onPress?: (e: GestureResponderEvent | number) => void; + disabled?: boolean; + buttonType?: MultipleStepsListItemButtonType; + leftText?: string; + showActivityIndicator?: boolean; + testID?: string; + }; + rightButton?: { + text?: string; + onPress?: () => void; + disabled?: boolean; + showActivityIndicator?: boolean; + }; +} + +const MultipleStepsListItem = (props: MultipleStepsListItemProps) => { + const { colors } = useTheme(); + const { + showActivityIndicator = false, + dashes = MultipleStepsListItemDashType.None, + circledText = '', + leftText = '', + checked = false, + isActionSheet = false, + actionSheetOptions = null, // Default to null or appropriate default + } = props; + const stylesHook = StyleSheet.create({ + provideKeyButton: { + backgroundColor: colors.buttonDisabledBackgroundColor, + }, + provideKeyButtonText: { + color: colors.buttonTextColor, + }, + vaultKeyCircle: { + backgroundColor: colors.buttonDisabledBackgroundColor, + }, + vaultKeyText: { + color: colors.alternativeTextColor, + }, + vaultKeyCircleSuccess: { + backgroundColor: colors.msSuccessBG, + }, + rowPartialLeftText: { + color: colors.alternativeTextColor, + }, + }); + const selfRef = useRef(null); // Create a ref for the component itself + + const handleOnPressForActionSheet = () => { + if (isActionSheet && actionSheetOptions) { + // Clone options to modify them + let modifiedOptions = { ...actionSheetOptions }; + + // Use 'selfRef' if the component uses its own ref, or 'ref' if it's using forwarded ref + const anchor = findNodeHandle(selfRef.current); + + if (anchor) { + // Attach the anchor only if it exists + modifiedOptions = { ...modifiedOptions, anchor }; + } + + ActionSheet.showActionSheetWithOptions(modifiedOptions, buttonIndex => { + // Call the original onPress function, if provided, and not cancelled + if (buttonIndex !== -1 && props.button?.onPress) { + props.button.onPress(buttonIndex); + } + }); + } + }; + + const renderDashes = (): StyleProp => { + switch (dashes) { + case MultipleStepsListItemDashType.TopAndBottom: + return { + width: 1, + borderStyle: 'dashed', + borderWidth: 0.8, + borderColor: '#c4c4c4', + top: 0, + bottom: 0, + marginLeft: 20, + position: 'absolute', + }; + case MultipleStepsListItemDashType.Bottom: + return { + width: 1, + borderStyle: 'dashed', + borderWidth: 0.8, + borderColor: '#c4c4c4', + top: '50%', + bottom: 0, + marginLeft: 20, + position: 'absolute', + }; + case MultipleStepsListItemDashType.Top: + return { + width: 1, + borderStyle: 'dashed', + borderWidth: 0.8, + borderColor: '#c4c4c4', + top: 0, + bottom: '50%', + marginLeft: 20, + position: 'absolute', + }; + default: + return {}; + } + }; + const buttonOpacity = { opacity: props.button?.disabled ? 0.5 : 1.0 }; + const rightButtonOpacity = { opacity: props.rightButton?.disabled ? 0.5 : 1.0 }; + const onPress = isActionSheet ? handleOnPressForActionSheet : props.button?.onPress; + return ( + + + + + {checked ? ( + + + + ) : circledText.length > 0 ? ( + + + {circledText} + + + ) : null} + {!showActivityIndicator && leftText.length > 0 && ( + + {leftText} + + )} + {showActivityIndicator && } + + {!showActivityIndicator && props.button && ( + <> + {props.button.buttonType === undefined || + (props.button.buttonType === MultipleStepsListItemButtonType.Full && ( + [ + Platform.OS === 'ios' && pressed ? styles.pressed : null, + styles.provideKeyButton, + stylesHook.provideKeyButton, + buttonOpacity, + ]} + onPress={onPress} + > + {props.button.text} + + ))} + {props.button.buttonType === MultipleStepsListItemButtonType.Partial && ( + + + {props.button.leftText} + + [ + Platform.OS === 'ios' && pressed ? styles.pressed : null, + styles.rowPartialRightButton, + stylesHook.provideKeyButton, + rightButtonOpacity, + ]} + onPress={onPress} + > + {props.button.showActivityIndicator ? ( + + ) : ( + + {props.button.text} + + )} + + + )} + + )} + {!showActivityIndicator && props.rightButton && checked && ( + + [pressed && styles.pressed, styles.rightButton]} + onPress={props.rightButton.onPress} + > + {props.rightButton.showActivityIndicator ? ( + + ) : ( + {props.rightButton.text} + )} + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + marginBottom: 16, + flex: 1, + justifyContent: 'space-between', + }, + buttonPartialContainer: { + borderRadius: 8, + borderColor: '#EEF0F4', + borderWidth: 1, + height: 48, + flex: 1, + justifyContent: 'space-between', + flexDirection: 'row', + alignItems: 'center', + paddingLeft: 16, + paddingRight: 8, + paddingVertical: 5, + marginLeft: 40, + }, + rowPartialRightButton: { + height: 36, + borderRadius: 8, + alignSelf: 'flex-end', + minWidth: 64, + justifyContent: 'center', + }, + itemKeyUnprovidedWrapper: { flexDirection: 'row' }, + vaultKeyCircle: { + width: 42, + height: 42, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + alignSelf: 'center', + }, + vaultKeyText: { fontSize: 18, fontWeight: 'bold' }, + vaultKeyTextWrapper: { justifyContent: 'center', alignContent: 'flex-start', paddingLeft: 16 }, + provideKeyButton: { + marginLeft: 40, + height: 48, + borderRadius: 8, + flex: 1, + justifyContent: 'center', + paddingHorizontal: 16, + }, + rightButton: { + borderRadius: 8, + textAlign: 'center', + }, + rightButtonContainer: { + alignContent: 'center', + justifyContent: 'center', + }, + activityIndicator: { + marginLeft: 40, + }, + provideKeyButtonText: { fontWeight: '600', fontSize: 15 }, + vaultKeyCircleSuccess: { + width: 42, + height: 42, + borderRadius: 25, + justifyContent: 'center', + alignItems: 'center', + }, + rowPartialLeftText: { + textAlign: 'center', + }, + pressed: { + opacity: 0.6, + }, +}); + +export default MultipleStepsListItem; diff --git a/components/PasswordInput.tsx b/components/PasswordInput.tsx new file mode 100644 index 00000000000..d2b3a6c25d9 --- /dev/null +++ b/components/PasswordInput.tsx @@ -0,0 +1,217 @@ +import React, { forwardRef, useImperativeHandle, useRef, useState } from 'react'; +import { Animated, Easing, StyleSheet, TextInput, View } from 'react-native'; +import { useTheme } from './themes'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import loc from '../loc'; + +export interface PasswordInputHandle { + focus: () => void; + blur: () => void; + clear: () => void; + showError: () => void; + showSuccess: () => void; + reset: () => void; + getValue: () => string; +} + +interface PasswordInputProps { + onSubmit: (password: string) => void; + placeholder?: string; + disabled?: boolean; + onChangeText?: (text: string) => void; +} + +export const PasswordInput = forwardRef( + ({ onSubmit, placeholder = loc._.enter_password, disabled = false, onChangeText }, ref) => { + const [password, setPassword] = useState(''); + const [isSuccess, setIsSuccess] = useState(false); + const inputRef = useRef(null); + const shakeAnimation = useRef(new Animated.Value(0)).current; + const checkmarkScale = useRef(new Animated.Value(0)).current; + const checkmarkOpacity = useRef(new Animated.Value(0)).current; + const { colors } = useTheme(); + + useImperativeHandle(ref, () => ({ + focus: () => { + inputRef.current?.focus(); + }, + blur: () => inputRef.current?.blur(), + clear: () => setPassword(''), + getValue: () => password, + showError: () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + setIsSuccess(false); + + // macOS-style shake animation - quick and snappy + Animated.sequence([ + Animated.timing(shakeAnimation, { + toValue: 10, + duration: 50, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: -10, + duration: 50, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 8, + duration: 45, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(shakeAnimation, { + toValue: 0, + duration: 45, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + ]).start(() => { + // Clear password after shake + setPassword(''); + }); + }, + showSuccess: () => { + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + setIsSuccess(true); + + // Dismiss keyboard on success + inputRef.current?.blur(); + + // Quick pop-in animation for checkmark + checkmarkScale.setValue(0); + checkmarkOpacity.setValue(0); + + Animated.parallel([ + Animated.spring(checkmarkScale, { + toValue: 1, + tension: 100, + friction: 5, + useNativeDriver: true, + }), + Animated.timing(checkmarkOpacity, { + toValue: 1, + duration: 200, + useNativeDriver: true, + }), + ]).start(); + }, + reset: () => { + setPassword(''); + setIsSuccess(false); + shakeAnimation.setValue(0); + checkmarkScale.setValue(0); + checkmarkOpacity.setValue(0); + }, + })); + + const handleSubmit = () => { + if (password.trim() && !isSuccess) { + onSubmit(password); + } + }; + + const stylesHook = StyleSheet.create({ + container: { + borderColor: isSuccess ? colors.successColor : colors.formBorder, + backgroundColor: colors.inputBackgroundColor, + }, + input: { + color: colors.foregroundColor, + }, + checkmark: { + color: colors.successColor, + }, + }); + + return ( + + { + setPassword(text); + onChangeText?.(text); + }} + clearButtonMode={isSuccess ? 'never' : 'while-editing'} + placeholder={placeholder} + placeholderTextColor={colors.alternativeTextColor} + secureTextEntry + autoCapitalize="none" + autoCorrect={false} + editable={!isSuccess} + onSubmitEditing={handleSubmit} + returnKeyType="done" + enablesReturnKeyAutomatically={true} + /> + + {isSuccess && ( + + + + + + )} + + ); + }, +); + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + borderRadius: 8, + borderWidth: 2, + paddingHorizontal: 16, + minHeight: 54, + width: '100%', + }, + input: { + flex: 1, + fontSize: 16, + paddingVertical: 12, + }, + checkmarkContainer: { + marginLeft: 12, + justifyContent: 'center', + alignItems: 'center', + }, + checkmarkCircle: { + width: 24, + height: 24, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + checkmark: { + width: 8, + height: 14, + borderBottomWidth: 3, + borderRightWidth: 3, + transform: [{ rotate: '45deg' }, { translateY: -2 }], + }, +}); + +PasswordInput.displayName = 'PasswordInput'; diff --git a/components/PsbtRawSheet.tsx b/components/PsbtRawSheet.tsx new file mode 100644 index 00000000000..93e16e90a79 --- /dev/null +++ b/components/PsbtRawSheet.tsx @@ -0,0 +1,105 @@ +import React from 'react'; +import { ScrollView, StyleSheet, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { RouteProp, useRoute } from '@react-navigation/native'; + +import BlueCard from './BlueCard'; +import BlueText from './BlueText'; +import CopyTextToClipboard from './CopyTextToClipboard'; +import { useTheme } from './themes'; +import loc from '../loc'; +import { SendDetailsStackParamList } from '../navigation/SendDetailsStackParamList'; + +const PsbtRawSheet = () => { + const route = useRoute>(); + const { colors } = useTheme(); + const { psbtBase64 } = route.params; + + return ( + + + {loc.send.psbt_raw_helper} + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + safeArea: { + flex: 1, + }, + wrap: { + alignItems: 'center', + width: '100%', + }, + label: { + fontWeight: '500', + alignSelf: 'stretch', + }, + cardTx: { + alignSelf: 'stretch', + borderWidth: StyleSheet.hairlineWidth, + borderRadius: 12, + marginTop: 20, + paddingHorizontal: 16, + paddingVertical: 16, + minHeight: 160, + maxHeight: 220, + overflow: 'hidden', + }, + cardTxScroll: { + maxHeight: 188, + }, + cardTxScrollContent: { + flexGrow: 1, + }, + cardTxCopy: { + width: '100%', + minHeight: 128, + justifyContent: 'flex-start', + }, + cardTxCopyCopied: { + justifyContent: 'center', + alignItems: 'center', + flexGrow: 1, + }, + cardTxText: { + marginVertical: 0, + fontWeight: '500', + fontSize: 14, + textAlign: 'left', + }, + cardTxTextCopied: { + textAlign: 'center', + }, +}); + +export default PsbtRawSheet; diff --git a/components/QRCode.tsx b/components/QRCode.tsx new file mode 100644 index 00000000000..6ce79ad4d20 --- /dev/null +++ b/components/QRCode.tsx @@ -0,0 +1,311 @@ +import Clipboard from '@react-native-clipboard/clipboard'; +import { encodeQR } from 'qr'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { Platform, StyleSheet, View, ViewStyle } from 'react-native'; +import Share from 'react-native-share'; +import Svg, { Defs, Image as SvgImage, LinearGradient, Path, Rect, Stop } from 'react-native-svg'; + +import loc from '../loc'; +import { ActionIcons } from '../typings/ActionIcons'; +import ToolTipMenu from './TooltipMenu'; +import { Action } from './types'; + +type ErrorCorrectionLevel = 'H' | 'Q' | 'M' | 'L'; + +interface QRCodeProps { + value: string; + size: number; + isLogoRendered?: boolean; + isMenuAvailable?: boolean; + logoSize?: number; + ecl?: ErrorCorrectionLevel; + onError?: (error?: unknown) => void; +} + +const GRADIENT_ID = 'qrgrad'; +const GRADIENT_STOP_1 = '#0c2550'; +const GRADIENT_STOP_2 = '#1e3a8a'; +const BACKGROUND = '#FFFFFF'; +const LOGO_BACKGROUND = '#FFFFFF'; + +const eclMap: Record = { + L: 'low', + M: 'medium', + Q: 'quartile', + H: 'high', +}; + +const actionIcons: { [key: string]: ActionIcons } = { + Copy: { iconValue: 'doc.on.doc' }, + Share: { iconValue: 'square.and.arrow.up' }, +}; + +const actionKeys = { Copy: 'copy', Share: 'share' }; + +// Copy-as-image is iOS/macOS-only — @react-native-clipboard/clipboard's setImage +// is not implemented on Android. Android users still get text-copy via the +// dedicated CopyTextToClipboard control rendered next to the QR on every screen. +const menuActions: Action[] = + Platform.OS === 'ios' || Platform.OS === 'macos' + ? [ + { id: actionKeys.Copy, text: loc.transactions.details_copy, icon: actionIcons.Copy }, + { id: actionKeys.Share, text: loc.receive.details_share, icon: actionIcons.Share }, + ] + : [{ id: actionKeys.Share, text: loc.receive.details_share, icon: actionIcons.Share }]; + +const roundUpToOdd = (n: number): number => { + const rounded = Math.ceil(n); + return rounded % 2 === 0 ? rounded + 1 : rounded; +}; + +const MATRIX_CACHE_MAX = 128; +const matrixCache = new Map(); + +const getCachedMatrix = (value: string, ecl: ErrorCorrectionLevel): boolean[][] => { + const key = `${ecl}|${value}`; + const hit = matrixCache.get(key); + if (hit) { + matrixCache.delete(key); + matrixCache.set(key, hit); + return hit; + } + const m = encodeQR(value, 'raw', { ecc: eclMap[ecl], border: 1 }); + matrixCache.set(key, m); + if (matrixCache.size > MATRIX_CACHE_MAX) { + const first = matrixCache.keys().next().value; + if (first !== undefined) matrixCache.delete(first); + } + return m; +}; + +type RenderPlan = { + N: number; + cell: number; + dataPath: string; + finderOrigins: Array<[number, number]>; + logoCells: number; + logoStart: number; +}; + +const PLAN_CACHE_MAX = 64; +const planCache = new Map(); + +const getCachedPlan = (value: string, ecl: ErrorCorrectionLevel, size: number, isLogoRendered: boolean, logoSize: number): RenderPlan => { + const key = `${ecl}|${size}|${isLogoRendered ? 'L' + logoSize : 'NL'}|${value}`; + const hit = planCache.get(key); + if (hit) { + planCache.delete(key); + planCache.set(key, hit); + return hit; + } + + const matrix = getCachedMatrix(value, ecl); + const N = matrix.length; + const cell = size / N; + + let logoCells = 0; + let logoStart = 0; + if (isLogoRendered) { + const desired = (logoSize + cell) / cell; + logoCells = Math.min(roundUpToOdd(desired), N); + logoStart = Math.floor((N - logoCells) / 2); + } + const logoEnd = logoStart + logoCells; + + const finderOrigins: Array<[number, number]> = + N >= 9 + ? [ + [1, 1], + [1, N - 8], + [N - 8, 1], + ] + : []; + const isInsideFinder = (r: number, c: number): boolean => + finderOrigins.some(([fr, fc]) => r >= fr && r < fr + 7 && c >= fc && c < fc + 7); + + let dataPath = ''; + for (let r = 0; r < N; r++) { + for (let c = 0; c < N; c++) { + if (!matrix[r][c]) continue; + if (isLogoRendered && r >= logoStart && r < logoEnd && c >= logoStart && c < logoEnd) continue; + if (isInsideFinder(r, c)) continue; + dataPath += `M${c * cell} ${r * cell}h${cell}v${cell}h-${cell}z`; + } + } + + const plan: RenderPlan = { N, cell, dataPath, finderOrigins, logoCells, logoStart }; + planCache.set(key, plan); + if (planCache.size > PLAN_CACHE_MAX) { + const first = planCache.keys().next().value; + if (first !== undefined) planCache.delete(first); + } + return plan; +}; + +const QRCode: React.FC = ({ + value = '', + size, + isLogoRendered = true, + isMenuAvailable = true, + logoSize = 90, + ecl = 'H', + onError, +}) => { + const svgRef = useRef(null); + + const plan = useMemo(() => { + try { + return getCachedPlan(value, ecl, size, isLogoRendered, logoSize); + } catch (e) { + onError?.(e); + return null; + } + }, [value, ecl, size, isLogoRendered, logoSize, onError]); + + const handleCopy = useCallback(() => { + if (!svgRef.current) return; + svgRef.current.toDataURL((data: string) => { + if (data) Clipboard.setImage(data); + }); + }, []); + + const handleShare = useCallback(() => { + if (!svgRef.current) return; + svgRef.current.toDataURL((data: string) => { + if (!data) { + console.warn('QRCode: toDataURL returned empty data'); + return; + } + const cleaned = data.replace(/(\r\n|\n|\r)/gm, ''); + Share.open({ + url: `data:image/png;base64,${cleaned}`, + type: 'image/png', + filename: 'qrcode', + failOnCancel: false, + // Workaround for Android FileProvider crash with data: URLs since react-native-share@12.1.1. + // Accepted at runtime but missing from ShareOptions types as of 12.2.6. + // See https://github.com/react-native-share/react-native-share/issues/1683 + // @ts-expect-error - useInternalStorage missing from ShareOptions type + useInternalStorage: true, + }).catch((error: Error) => console.warn('QRCode share failed:', error)); + }); + }, []); + + const onPressMenuItem = useCallback( + (id: string) => { + if (id === actionKeys.Copy) handleCopy(); + else if (id === actionKeys.Share) handleShare(); + }, + [handleCopy, handleShare], + ); + + const stylesHook = StyleSheet.create({ + placeholder: { width: size, height: size, backgroundColor: BACKGROUND }, + }); + + const qrButtonStyle: ViewStyle = { + width: size, + height: size, + justifyContent: 'center', + alignItems: 'center', + }; + + const renderQR = useMemo(() => { + if (!plan) return null; + const { cell, dataPath, finderOrigins, logoCells, logoStart } = plan; + const gradFill = `url(#${GRADIENT_ID})`; + + const finderShapes: React.ReactElement[] = []; + finderOrigins.forEach(([fr, fc], i) => { + const x = fc * cell; + const y = fr * cell; + finderShapes.push( + , + , + , + ); + }); + + const backdropX = logoStart * cell; + const backdropY = logoStart * cell; + const backdropSize = logoCells * cell; + const logoCenter = size / 2; + + return ( + + + + + + + + + {dataPath ? : null} + {finderShapes} + {isLogoRendered && logoCells > 0 && ( + <> + + + + )} + + ); + }, [plan, size, isLogoRendered, logoSize]); + + const content = renderQR ?? ; + + return ( + + {isMenuAvailable ? ( + + {content} + + ) : ( + content + )} + + ); +}; + +export default QRCode; + +const styles = StyleSheet.create({ + container: { alignItems: 'center', justifyContent: 'center' }, +}); diff --git a/components/QRCodeComponent.tsx b/components/QRCodeComponent.tsx deleted file mode 100644 index 4af4d21fbc2..00000000000 --- a/components/QRCodeComponent.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { useRef } from 'react'; -import { View, StyleSheet, Platform } from 'react-native'; -import QRCode from 'react-native-qrcode-svg'; -import ToolTipMenu from './TooltipMenu'; -import Share from 'react-native-share'; -import loc from '../loc'; -import Clipboard from '@react-native-clipboard/clipboard'; -import { useTheme } from '@react-navigation/native'; - -interface QRCodeComponentProps { - value: string; - isLogoRendered?: boolean; - isMenuAvailable?: boolean; - logoSize?: number; - size?: number; - ecl?: 'H' | 'Q' | 'M' | 'L'; - onError?: () => void; -} - -interface ActionIcons { - iconType: 'SYSTEM'; - iconValue: string; -} - -interface ActionType { - Share: 'share'; - Copy: 'copy'; -} - -interface Action { - id: string; - text: string; - icon: ActionIcons; -} - -const actionKeys: ActionType = { - Share: 'share', - Copy: 'copy', -}; - -interface ActionIcons { - iconType: 'SYSTEM'; - iconValue: string; -} - -const actionIcons: { [key: string]: ActionIcons } = { - Share: { - iconType: 'SYSTEM', - iconValue: 'square.and.arrow.up', - }, - Copy: { - iconType: 'SYSTEM', - iconValue: 'doc.on.doc', - }, -}; - -const QRCodeComponent: React.FC = ({ - value = '', - isLogoRendered = true, - isMenuAvailable = true, - logoSize = 90, - size = 300, - ecl = 'H', - onError = () => {}, -}) => { - const qrCode = useRef(); - const { colors } = useTheme(); - - const handleShareQRCode = () => { - qrCode.current.toDataURL((data: string) => { - data = data.replace(/(\r\n|\n|\r)/gm, ''); - const shareImageBase64 = { - url: `data:image/png;base64,${data}`, - }; - Share.open(shareImageBase64).catch((error: any) => console.log(error)); - }); - }; - - const onPressMenuItem = (id: string) => { - if (id === actionKeys.Share) { - handleShareQRCode(); - } else if (id === actionKeys.Copy) { - qrCode.current.toDataURL(Clipboard.setImage); - } - }; - - const menuActions = (): Action[] => { - const actions: Action[] = []; - if (Platform.OS === 'ios' || Platform.OS === 'macos') { - actions.push({ - id: actionKeys.Copy, - text: loc.transactions.details_copy, - icon: actionIcons.Copy, - }); - } - actions.push({ - id: actionKeys.Share, - text: loc.receive.details_share, - icon: actionIcons.Share, - }); - return actions; - }; - - const renderQRCode = ( - (qrCode.current = c)} - onError={onError} - /> - ); - - return ( - - {isMenuAvailable ? ( - - {renderQRCode} - - ) : ( - renderQRCode - )} - - ); -}; - -export default QRCodeComponent; - -const styles = StyleSheet.create({ - qrCodeContainer: { borderWidth: 6, borderRadius: 8, borderColor: '#FFFFFF' }, -}); diff --git a/components/ReplaceFeeSuggestions.tsx b/components/ReplaceFeeSuggestions.tsx new file mode 100644 index 00000000000..310e14debca --- /dev/null +++ b/components/ReplaceFeeSuggestions.tsx @@ -0,0 +1,218 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { View, Text, TextInput, TouchableOpacity, Keyboard, StyleSheet } from 'react-native'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import BlueText from './BlueText'; +import loc, { formatStringAddTwoWhiteSpaces } from '../loc'; +import NetworkTransactionFees, { NetworkTransactionFee, NetworkTransactionFeeType } from '../models/networkTransactionFees'; +import { useTheme } from './themes'; +import { DismissKeyboardInputAccessory, DismissKeyboardInputAccessoryViewID } from './DismissKeyboardInputAccessory'; + +interface ReplaceFeeSuggestionsProps { + onFeeSelected: (fee: number) => void; + transactionMinimum?: number; +} + +const ReplaceFeeSuggestions: React.FC = ({ onFeeSelected, transactionMinimum = 1 }) => { + const [networkFees, setNetworkFees] = useState(null); + const [selectedFeeType, setSelectedFeeType] = useState(NetworkTransactionFeeType.FAST); + const [customFeeValue, setCustomFeeValue] = useState('1'); + const customTextInput = useRef(null); + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + activeButton: { + backgroundColor: colors.incomingBackgroundColor, + }, + buttonText: { + color: colors.successColor, + }, + timeContainer: { + backgroundColor: colors.successColor, + }, + timeText: { + color: colors.background, + }, + rateText: { + color: colors.successColor, + }, + customFeeInput: { + backgroundColor: colors.inputBackgroundColor, + borderBottomColor: colors.formBorder, + borderColor: colors.formBorder, + }, + alternativeText: { + color: colors.alternativeTextColor, + }, + }); + + const fetchNetworkFees = useCallback(async () => { + try { + const cachedNetworkTransactionFees = JSON.parse((await AsyncStorage.getItem(NetworkTransactionFee.StorageKey)) || '{}'); + + if (cachedNetworkTransactionFees && 'fastestFee' in cachedNetworkTransactionFees) { + setNetworkFees(cachedNetworkTransactionFees); + onFeeSelected(cachedNetworkTransactionFees.fastestFee); + setSelectedFeeType(NetworkTransactionFeeType.FAST); + } + } catch (_) {} + const fees = await NetworkTransactionFees.recommendedFees(); + setNetworkFees(fees); + onFeeSelected(fees.fastestFee); + setSelectedFeeType(NetworkTransactionFeeType.FAST); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + fetchNetworkFees(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const handleFeeSelection = (feeType: NetworkTransactionFeeType) => { + if (feeType === NetworkTransactionFeeType.CUSTOM) { + setSelectedFeeType(feeType); + return; + } + + Keyboard.dismiss(); + if (networkFees) { + switch (feeType) { + case NetworkTransactionFeeType.FAST: + onFeeSelected(networkFees.fastestFee); + break; + case NetworkTransactionFeeType.MEDIUM: + onFeeSelected(networkFees.mediumFee); + break; + case NetworkTransactionFeeType.SLOW: + onFeeSelected(networkFees.slowFee); + break; + } + + setSelectedFeeType(feeType); + } + }; + + const handleCustomFeeChange = (customFee: string) => { + const sanitizedFee = customFee.replace(/[^\d.,]/g, '').replace(/([.,].*?)[.,]/g, '$1'); + setCustomFeeValue(sanitizedFee); + onFeeSelected(Number(sanitizedFee.replace(',', '.'))); + setSelectedFeeType(NetworkTransactionFeeType.CUSTOM); + }; + + return ( + + {networkFees && + [ + { + label: loc.send.fee_fast, + time: loc.send.fee_10m, + type: NetworkTransactionFeeType.FAST, + rate: networkFees.fastestFee, + active: selectedFeeType === NetworkTransactionFeeType.FAST, + }, + { + label: formatStringAddTwoWhiteSpaces(loc.send.fee_medium), + time: loc.send.fee_3h, + type: NetworkTransactionFeeType.MEDIUM, + rate: networkFees.mediumFee, + active: selectedFeeType === NetworkTransactionFeeType.MEDIUM, + }, + { + label: loc.send.fee_slow, + time: loc.send.fee_1d, + type: NetworkTransactionFeeType.SLOW, + rate: networkFees.slowFee, + active: selectedFeeType === NetworkTransactionFeeType.SLOW, + }, + ].map(({ label, type, time, rate, active }) => ( + handleFeeSelection(type)} + style={[styles.button, active && stylesHook.activeButton]} + > + + {label} + + ~{time} + + + + {rate} sat/byte + + + ))} + customTextInput.current?.focus()} + style={[styles.button, selectedFeeType === NetworkTransactionFeeType.CUSTOM && stylesHook.activeButton]} + > + + {formatStringAddTwoWhiteSpaces(loc.send.fee_custom)} + + + { + setSelectedFeeType(NetworkTransactionFeeType.CUSTOM); + onFeeSelected(Number(customFeeValue)); + }} + placeholder={loc.send.fee_satvbyte} + placeholderTextColor="#81868e" + inputAccessoryViewID={DismissKeyboardInputAccessoryViewID} + /> + + sat/byte + + + {loc.formatString(loc.send.fee_replace_minvb, { min: transactionMinimum })} + + ); +}; + +const styles = StyleSheet.create({ + button: { + paddingHorizontal: 16, + paddingVertical: 8, + marginBottom: 10, + borderRadius: 8, + }, + buttonContent: { + justifyContent: 'space-between', + flexDirection: 'row', + alignItems: 'center', + }, + buttonText: { + fontSize: 22, + fontWeight: '600', + }, + timeContainer: { + borderRadius: 5, + paddingHorizontal: 6, + paddingVertical: 3, + }, + rateContainer: { + justifyContent: 'flex-end', + flexDirection: 'row', + alignItems: 'center', + }, + customFeeInputContainer: { + marginTop: 5, + }, + customFeeInput: { + borderBottomWidth: 0.5, + borderRadius: 4, + borderWidth: 1.0, + color: '#81868e', + flex: 1, + marginRight: 10, + minHeight: 33, + paddingRight: 5, + paddingLeft: 5, + }, +}); + +export default ReplaceFeeSuggestions; diff --git a/components/SafeArea.tsx b/components/SafeArea.tsx new file mode 100644 index 00000000000..dcecc8ddbd7 --- /dev/null +++ b/components/SafeArea.tsx @@ -0,0 +1,48 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, ViewProps, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from './themes'; + +interface SafeAreaProps extends ViewProps { + floatingButtonHeight?: number; + orientation?: 'portrait' | 'landscape'; + ignoreTopInset?: boolean; +} + +const SafeArea = (props: SafeAreaProps) => { + const { style, floatingButtonHeight, ignoreTopInset = false, ...otherProps } = props; + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + const padding = useMemo( + () => + props.orientation === 'portrait' + ? { + paddingTop: ignoreTopInset ? 0 : insets.top, + paddingBottom: insets.bottom, + } + : { + paddingTop: ignoreTopInset ? 0 : insets.top, + paddingBottom: insets.bottom + (floatingButtonHeight ?? 0), + paddingLeft: insets.left, + paddingRight: insets.right, + }, + [insets, props.orientation, floatingButtonHeight, ignoreTopInset], + ); + + const componentStyle = useMemo(() => { + return StyleSheet.compose( + { + flex: 1, + backgroundColor: colors.background, + ...padding, + }, + style, + ); + }, [colors.background, padding, style]); + + return ; +}; + +export default SafeArea; diff --git a/components/SafeAreaFlatList.tsx b/components/SafeAreaFlatList.tsx new file mode 100644 index 00000000000..97085597eb6 --- /dev/null +++ b/components/SafeAreaFlatList.tsx @@ -0,0 +1,47 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, FlatList, FlatListProps } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from './themes'; + +interface SafeAreaFlatListProps extends FlatListProps { + headerHeight?: number; + floatingButtonHeight?: number; +} + +const SafeAreaFlatList = (props: SafeAreaFlatListProps) => { + const { style, contentContainerStyle, headerHeight = 0, floatingButtonHeight = 0, ...otherProps } = props; + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + const componentStyle = useMemo(() => { + return StyleSheet.compose({ flex: 1, backgroundColor: colors.background }, style); + }, [colors.background, style]); + + const contentStyle = useMemo(() => { + // Calculate top padding + const topPadding = (() => { + // If explicit headerHeight is provided, use it + if (headerHeight > 0) { + return headerHeight; + } + // iOS safe area handling is done via ListHeaderComponent typically + // Android screens should explicitly pass headerHeight if needed + return 0; + })(); + + return StyleSheet.compose( + { + paddingBottom: insets.bottom + floatingButtonHeight, + paddingLeft: insets.left, + paddingRight: insets.right, + paddingTop: topPadding, + }, + contentContainerStyle, + ); + }, [insets, contentContainerStyle, headerHeight, floatingButtonHeight]); + + return ; +}; + +export default SafeAreaFlatList; diff --git a/components/SafeAreaScrollView.tsx b/components/SafeAreaScrollView.tsx new file mode 100644 index 00000000000..7a7d50b7f1a --- /dev/null +++ b/components/SafeAreaScrollView.tsx @@ -0,0 +1,77 @@ +import React, { useMemo, forwardRef } from 'react'; +import { StyleSheet, ScrollView, ScrollViewProps } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { useTheme } from './themes'; + +interface SafeAreaScrollViewProps extends ScrollViewProps { + floatingButtonHeight?: number; + headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true) + disableDefaultTopPadding?: boolean; +} + +const SafeAreaScrollView = forwardRef((props, ref) => { + const { + style, + contentContainerStyle, + floatingButtonHeight = 0, + headerHeight = 0, + disableDefaultTopPadding = false, + ...otherProps + } = props; + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + const componentStyle = useMemo(() => { + return StyleSheet.compose({ flex: 1, backgroundColor: colors.background }, style); + }, [colors.background, style]); + + const contentStyle = useMemo(() => { + // Calculate base inset paddings with proper typing + const basePadding: { + paddingBottom: number; + paddingTop: number; + paddingLeft?: number; + paddingRight?: number; + } = { + paddingBottom: insets.bottom + floatingButtonHeight, // Add extra padding for the floating button + paddingTop: (() => { + // If explicit headerHeight is provided, use it + if (headerHeight > 0) { + return headerHeight; + } + if (disableDefaultTopPadding) { + return 0; + } + // Preserve legacy behavior for existing screens + return insets.top > 0 ? 5 : 0; + })(), + }; + + // Only add horizontal paddings if they aren't explicitly defined in contentContainerStyle + if (!StyleSheet.flatten(contentContainerStyle)?.paddingHorizontal && !StyleSheet.flatten(contentContainerStyle)?.paddingLeft) { + basePadding.paddingLeft = insets.left; + } + + if (!StyleSheet.flatten(contentContainerStyle)?.paddingHorizontal && !StyleSheet.flatten(contentContainerStyle)?.paddingRight) { + basePadding.paddingRight = insets.right; + } + + // Now compose with contentContainerStyle to ensure passed styles override defaults + return StyleSheet.compose(basePadding, contentContainerStyle); + }, [insets, contentContainerStyle, floatingButtonHeight, headerHeight, disableDefaultTopPadding]); + + return ( + + ); +}); + +export default SafeAreaScrollView; diff --git a/components/SafeAreaSectionList.tsx b/components/SafeAreaSectionList.tsx new file mode 100644 index 00000000000..674838bac89 --- /dev/null +++ b/components/SafeAreaSectionList.tsx @@ -0,0 +1,65 @@ +import React, { useMemo } from 'react'; +import { StyleSheet, SectionList, SectionListProps, Platform, StatusBar } from 'react-native'; + +import { useTheme } from './themes'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; + +interface SafeAreaSectionListProps extends SectionListProps { + floatingButtonHeight?: number; + ignoreTopInset?: boolean; + headerHeight?: number; // Additional header height to account for (e.g., when headerTransparent is true) +} + +const SafeAreaSectionList = (props: SafeAreaSectionListProps) => { + const { style, contentContainerStyle, floatingButtonHeight = 0, ignoreTopInset = false, headerHeight = 0, ...otherProps } = props; + const { colors } = useTheme(); + const insets = useSafeAreaInsets(); + + const componentStyle = useMemo(() => { + return StyleSheet.compose({ flex: 1, backgroundColor: colors.background }, style); + }, [colors.background, style]); + + const contentStyle = useMemo(() => { + // Calculate top padding + const topPadding = (() => { + // If ignoreTopInset is true, don't apply any top padding + if (ignoreTopInset) { + return 0; + } + // If explicit headerHeight is provided, use it + if (headerHeight > 0) { + return headerHeight; + } + // On Android with transparent headers, we need to account for header height + if (Platform.OS === 'android' && insets.top > 0) { + return 56 + (StatusBar.currentHeight || insets.top); + } + // iOS safe area + return insets.top; + })(); + + return StyleSheet.compose( + { + paddingBottom: insets.bottom + floatingButtonHeight, // Add extra padding for the floating button + paddingRight: insets.right, + paddingLeft: insets.left, + paddingTop: topPadding, + }, + contentContainerStyle, + ); + }, [insets, contentContainerStyle, floatingButtonHeight, ignoreTopInset, headerHeight]); + + return ( + + ); +}; + +export default SafeAreaSectionList; diff --git a/components/SaveFileButton.tsx b/components/SaveFileButton.tsx new file mode 100644 index 00000000000..fcd449f5b65 --- /dev/null +++ b/components/SaveFileButton.tsx @@ -0,0 +1,66 @@ +import React, { ReactNode, useCallback } from 'react'; +import { StyleProp, TouchableOpacityProps, ViewStyle } from 'react-native'; + +import * as fs from '../blue_modules/fs'; +import loc from '../loc'; +import { ActionIcons } from '../typings/ActionIcons'; +import ToolTipMenu from './TooltipMenu'; +import { Action } from './types'; + +interface SaveFileButtonProps extends TouchableOpacityProps { + fileName: string; + fileContent: string; + children?: ReactNode; + style?: StyleProp; + afterOnPress?: () => void; + beforeOnPress?: (() => Promise) | (() => void); +} + +const SaveFileButton: React.FC = ({ fileName, fileContent, children, style, beforeOnPress, afterOnPress }) => { + const handlePressMenuItem = useCallback( + async (actionId: string) => { + if (beforeOnPress) { + await beforeOnPress(); + } + const action = actions.find(a => a.id === actionId); + + if (action?.id === 'save') { + await fs.writeFileAndExport(fileName, fileContent, false).finally(() => { + afterOnPress?.(); + }); + } else if (action?.id === 'share') { + await fs.writeFileAndExport(fileName, fileContent, true).finally(() => { + afterOnPress?.(); + }); + } + }, + [afterOnPress, beforeOnPress, fileContent, fileName], + ); + + return ( + + {children} + + ); +}; + +export default SaveFileButton; + +const actionIcons: { [key: string]: ActionIcons } = { + Share: { + iconValue: 'square.and.arrow.up', + }, + Save: { + iconValue: 'square.and.arrow.down', + }, +}; +const actions: Action[] = [ + { id: 'save', text: loc._.save, icon: actionIcons.Save }, + { id: 'share', text: loc.receive.details_share, icon: actionIcons.Share }, +]; diff --git a/components/SecondButton.tsx b/components/SecondButton.tsx new file mode 100644 index 00000000000..cec04e75180 --- /dev/null +++ b/components/SecondButton.tsx @@ -0,0 +1,80 @@ +import React, { forwardRef } from 'react'; +import { StyleSheet, Text, TouchableOpacity, View, ActivityIndicator } from 'react-native'; +import Icon, { type IconProps } from './Icon'; + +import { useTheme } from './themes'; + +type IconButtonProps = Pick & { color: string }; + +type SecondButtonProps = { + backgroundColor?: string; + disabled?: boolean; + icon?: IconButtonProps; + title: string; + textColor?: string; + onPress?: () => void; + loading?: boolean; + testID?: string; +}; + +export const SecondButton = forwardRef, SecondButtonProps>((props, ref) => { + const { colors } = useTheme(); + let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonGrayBackgroundColor; + let fontColor = props.textColor ?? colors.secondButtonTextColor; + if (props.disabled === true) { + backgroundColor = colors.buttonDisabledBackgroundColor; + fontColor = colors.buttonDisabledTextColor; + } + + const buttonView = props.loading ? ( + + ) : ( + + {props.icon && } + {props.title && {props.title}} + + ); + + return props.onPress ? ( + + {buttonView} + + ) : ( + {buttonView} + ); +}); + +const styles = StyleSheet.create({ + button: { + minHeight: 45, + height: 48, + maxHeight: 48, + borderRadius: 7, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 16, + flexGrow: 1, + }, + content: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + text: { + marginHorizontal: 8, + fontSize: 16, + fontWeight: '600', + }, + view: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/components/SeedWords.tsx b/components/SeedWords.tsx new file mode 100644 index 00000000000..6d39589a0ff --- /dev/null +++ b/components/SeedWords.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { useTheme } from './themes'; +import { useLocale } from '@react-navigation/native'; + +const SeedWords = ({ seed }: { seed: string }) => { + const words = seed.split(/\s/); + const { colors } = useTheme(); + const { direction } = useLocale(); + + const stylesHook = StyleSheet.create({ + word: { + backgroundColor: colors.inputBackgroundColor, + }, + wortText: { + color: colors.labelText, + }, + secret: { + flexDirection: direction === 'rtl' ? 'row-reverse' : 'row', + }, + }); + + return ( + + {words.map((secret, index) => { + const text = `${index + 1}. ${secret} `; + return ( + + + {text} + + + ); + })} + + {seed} + + + ); +}; + +const styles = StyleSheet.create({ + word: { + marginRight: 8, + marginBottom: 8, + paddingTop: 6, + paddingBottom: 6, + paddingLeft: 8, + paddingRight: 8, + borderRadius: 4, + }, + wortText: { + fontWeight: 'bold', + textAlign: 'left', + fontSize: 17, + }, + secret: { + flexWrap: 'wrap', + justifyContent: 'center', + }, + hiddenText: { + height: 0, + width: 0, + }, +}); + +export default SeedWords; diff --git a/components/SegmentedControl.tsx b/components/SegmentedControl.tsx new file mode 100644 index 00000000000..6b1f5de863c --- /dev/null +++ b/components/SegmentedControl.tsx @@ -0,0 +1,60 @@ +import React, { useCallback } from 'react'; +import { View, StyleSheet, NativeSyntheticEvent } from 'react-native'; +import NativeSegmentedControl from '../codegen/SegmentedControlNativeComponent'; + +interface SegmentedControlProps { + values: string[]; + selectedIndex: number; + onChange: (index: number) => void; + testID?: string; +} + +interface SegmentedControlEvent { + selectedIndex: number; +} + +const SegmentedControl: React.FC = ({ values, selectedIndex, onChange, testID }) => { + const handleChange = useCallback( + (event: NativeSyntheticEvent) => { + if (event?.nativeEvent?.selectedIndex !== undefined) { + onChange(event.nativeEvent.selectedIndex); + } + }, + [onChange], + ); + + if (!Array.isArray(values) || values.length === 0) { + return null; + } + + return ( + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + width: '100%', + marginHorizontal: 0, + marginBottom: 18, + minHeight: 40, + }, + segmentedControl: { + height: 40, + }, +}); + +export default SegmentedControl; diff --git a/components/SettingsBlockExplorerCustomUrlListItem.tsx b/components/SettingsBlockExplorerCustomUrlListItem.tsx new file mode 100644 index 00000000000..861b9d4cb6c --- /dev/null +++ b/components/SettingsBlockExplorerCustomUrlListItem.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { StyleSheet, TextInput, View } from 'react-native'; +import { useTheme } from './themes'; +import loc from '../loc'; +import { SettingsListItem } from './SettingsSection'; + +interface SettingsBlockExplorerCustomUrlItemProps { + isCustomEnabled: boolean; + onSwitchToggle: (value: boolean) => void; + customUrl: string; + onCustomUrlChange: (url: string) => void; + onSubmitCustomUrl: () => void; + inputRef?: React.RefObject; +} + +const SettingsBlockExplorerCustomUrlItem: React.FC = ({ + isCustomEnabled, + onSwitchToggle, + customUrl, + onCustomUrlChange, + onSubmitCustomUrl, + inputRef, +}) => { + const { colors } = useTheme(); + + return ( + + + + {isCustomEnabled && ( + + + + + + )} + + ); +}; + +export default SettingsBlockExplorerCustomUrlItem; + +const styles = StyleSheet.create({ + inputWrapper: { + paddingHorizontal: 16, + paddingBottom: 16, + }, + uriContainer: { + flexDirection: 'row', + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + alignItems: 'center', + minHeight: 44, + }, + uriText: { + flex: 1, + minHeight: 36, + }, +}); diff --git a/components/SettingsSection.tsx b/components/SettingsSection.tsx new file mode 100644 index 00000000000..9391228c7e3 --- /dev/null +++ b/components/SettingsSection.tsx @@ -0,0 +1,194 @@ +import React, { forwardRef } from 'react'; +import { Pressable, ScrollView, StyleProp, StyleSheet, Text, TextProps, View, ViewStyle } from 'react-native'; +import Ionicons from '@react-native-vector-icons/ionicons'; + +import BlueText from './BlueText'; +import ListItem, { ListItemProps } from './ListItem'; +import SafeAreaScrollView from './SafeAreaScrollView'; +import { useTheme } from './themes'; + +export type SettingsIconName = + | 'settings' + | 'currency' + | 'language' + | 'security' + | 'network' + | 'tools' + | 'about' + | 'notifications' + | 'lightning' + | 'blockExplorer' + | 'electrum' + | 'licensing' + | 'releaseNotes' + | 'selfTest' + | 'performance' + | 'github' + | 'search' + | 'paperPlane' + | 'key'; + +type IoniconsName = React.ComponentProps['name']; + +interface IconConfig { + name: IoniconsName; + color: string; + darkColor: string; + backgroundColor: string; +} + +const iconConfigs: Record = { + settings: { name: 'settings-outline', color: '#5F6368', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + currency: { name: 'cash-outline', color: '#0F9D58', darkColor: '#7EE0A4', backgroundColor: 'rgba(52, 199, 89, 0.12)' }, + language: { name: 'language-outline', color: '#F4B400', darkColor: '#FFD580', backgroundColor: 'rgba(255, 149, 0, 0.12)' }, + security: { name: 'shield-checkmark-outline', color: '#DB4437', darkColor: '#FF8E8E', backgroundColor: 'rgba(255, 59, 48, 0.12)' }, + network: { name: 'globe-outline', color: '#1A73E8', darkColor: '#82B1FF', backgroundColor: 'rgba(0, 122, 255, 0.12)' }, + tools: { name: 'construct-outline', color: '#673AB7', darkColor: '#D0BCFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + about: { name: 'information-circle-outline', color: '#5F6368', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + notifications: { name: 'notifications-outline', color: '#1A73E8', darkColor: '#82B1FF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + lightning: { name: 'flash-outline', color: '#F4B400', darkColor: '#FFD580', backgroundColor: 'rgba(255, 149, 0, 0.12)' }, + blockExplorer: { name: 'search-outline', color: '#1A73E8', darkColor: '#82B1FF', backgroundColor: 'rgba(0, 122, 255, 0.12)' }, + electrum: { name: 'server-outline', color: '#0F9D58', darkColor: '#69F0AE', backgroundColor: 'rgba(52, 199, 89, 0.12)' }, + licensing: { name: 'shield-checkmark-outline', color: '#24292e', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + releaseNotes: { name: 'document-text-outline', color: '#9AA0AA', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + selfTest: { name: 'flask-outline', color: '#FC0D44', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + performance: { name: 'speedometer-outline', color: '#FC0D44', darkColor: '#FFFFFF', backgroundColor: 'rgba(142, 142, 147, 0.12)' }, + github: { name: 'logo-github', color: '#24292e', darkColor: '#FFFFFF', backgroundColor: 'rgba(24, 23, 23, 0.1)' }, + search: { name: 'search-outline', color: '#1A73E8', darkColor: '#82B1FF', backgroundColor: 'rgba(0, 122, 255, 0.12)' }, + paperPlane: { name: 'paper-plane-outline', color: '#1A73E8', darkColor: '#82B1FF', backgroundColor: 'rgba(0, 122, 255, 0.12)' }, + key: { name: 'key-outline', color: '#0F9D58', darkColor: '#69F0AE', backgroundColor: 'rgba(52, 199, 89, 0.12)' }, +}; + +const SettingsIcon: React.FC<{ name: SettingsIconName }> = ({ name }) => { + const { dark } = useTheme(); + const config = iconConfigs[name]; + return ( + + + + ); +}; + +export const SettingsListItem: React.FC = ({ + iconName, + containerStyle, + leftAvatar, + ...rest +}) => ( + : leftAvatar} + /> +); + +// SafeAreaScrollView with the default top spacing settings screens use before their first section +export const SettingsScrollView = forwardRef>( + ({ contentContainerStyle, ...rest }, ref) => ( + + ), +); +SettingsScrollView.displayName = 'SettingsScrollView'; + +// Subdued explanation text used inside section bodies +export const SettingsFootnote: React.FC = ({ style, ...rest }) => { + const { colors } = useTheme(); + return ; +}; + +interface SettingsSectionProps { + title?: string; + headerRight?: React.ReactNode; + onHeaderPress?: () => void; + containerStyle?: StyleProp; + children?: React.ReactNode; +} + +export const SettingsSection: React.FC = ({ title, headerRight, onHeaderPress, containerStyle, children }) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + header: { backgroundColor: colors.cardSectionHeaderBackground }, + headerText: { color: colors.foregroundColor }, + body: { backgroundColor: colors.cardSectionBackground }, + }); + + const header = + title || headerRight ? ( + + {title ? {title} : null} + {headerRight} + + ) : null; + + return ( + + {onHeaderPress && header ? ( + (pressed ? styles.headerPressed : undefined)}> + {header} + + ) : ( + header + )} + {children} + + ); +}; + +const styles = StyleSheet.create({ + scrollContent: { + paddingTop: 20, + }, + footnote: { + fontSize: 14, + lineHeight: 20, + }, + cardContent: { + padding: 16, + }, + listCard: { + marginHorizontal: 16, + marginTop: 16, + marginBottom: 40, + // SafeAreaFlatList injects bottom-inset padding into the content container; on a clipped, + // background-colored card that padding shows as empty space inside the card, so cancel it + paddingBottom: 0, + borderRadius: 12, + overflow: 'hidden', + }, + card: { + marginHorizontal: 16, + marginBottom: 40, + borderRadius: 12, + overflow: 'hidden', + }, + header: { + paddingVertical: 16, + paddingHorizontal: 16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + headerText: { + fontSize: 17, + fontWeight: '600', + flexShrink: 1, + }, + headerPressed: { + opacity: 0.75, + }, + iconContainer: { + width: 28, + height: 28, + borderRadius: 6, + alignItems: 'center', + justifyContent: 'center', + }, + transparentBackground: { + backgroundColor: 'transparent', + }, +}); + +// Shared styles for screens that build card content or FlatList-based cards themselves +export const settingsCardContent = styles.cardContent; +export const settingsListCard = styles.listCard; +export const settingsSectionHeaderText = styles.headerText; diff --git a/components/SquareButton.js b/components/SquareButton.js deleted file mode 100644 index 85e85214d43..00000000000 --- a/components/SquareButton.js +++ /dev/null @@ -1,40 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import React, { forwardRef } from 'react'; -import { TouchableOpacity, View, Text } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -export const SquareButton = forwardRef((props, ref) => { - const { colors } = useTheme(); - let backgroundColor = props.backgroundColor ? props.backgroundColor : colors.buttonBlueBackgroundColor; - let fontColor = colors.buttonTextColor; - if (props.disabled === true) { - backgroundColor = colors.buttonDisabledBackgroundColor; - fontColor = colors.buttonDisabledTextColor; - } - - return ( - - - {props.icon && } - {props.title && {props.title}} - - - ); -}); diff --git a/components/SquareButton.tsx b/components/SquareButton.tsx new file mode 100644 index 00000000000..8fce329b326 --- /dev/null +++ b/components/SquareButton.tsx @@ -0,0 +1,48 @@ +import React, { forwardRef } from 'react'; +import { StyleProp, StyleSheet, Text, TouchableOpacity, View, ViewStyle } from 'react-native'; + +import { useTheme } from './themes'; + +interface SquareButtonProps { + title: string; + onPress?: () => void; + style?: StyleProp; + testID?: string; +} + +export const SquareButton = forwardRef, SquareButtonProps>((props, ref) => { + const { title, onPress, style, testID } = props; + const { colors } = useTheme(); + + const hookStyles = StyleSheet.create({ + text: { + color: colors.buttonTextColor, + }, + }); + + const buttonView = ( + + {title} + + ); + + return onPress ? ( + + {buttonView} + + ) : ( + {buttonView} + ); +}); + +const styles = StyleSheet.create({ + contentContainer: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, + text: { + marginHorizontal: 8, + fontSize: 16, + }, +}); diff --git a/components/SquareEnumeratedWords.js b/components/SquareEnumeratedWords.js deleted file mode 100644 index 680ab840e67..00000000000 --- a/components/SquareEnumeratedWords.js +++ /dev/null @@ -1,81 +0,0 @@ -import React from 'react'; -import { useTheme } from '@react-navigation/native'; -import { TouchableOpacity, Text, StyleSheet, View } from 'react-native'; -import PropTypes from 'prop-types'; - -export const SquareEnumeratedWordsContentAlign = Object.freeze({ left: 'flex-start', center: 'center', right: 'flex-end' }); -const SquareEnumeratedWords = props => { - const { - entries = ['Empty entries prop. Please provide an array of strings'], - appendNumber = true, - contentAlign = SquareEnumeratedWordsContentAlign.center, - } = props; - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - entryTextContainer: { - backgroundColor: colors.inputBackgroundColor, - }, - entryText: { - color: colors.labelText, - }, - }); - - const renderSecret = () => { - const component = []; - const entriesObject = entries.entries(); - for (const [index, secret] of entriesObject) { - if (entries.length > 1) { - const text = appendNumber ? `${index + 1}. ${secret} ` : `${secret} `; - component.push( - - - {text} - - , - ); - } else { - component.push( - - - {secret} - - , - ); - } - } - return component; - }; - - return {renderSecret()}; -}; - -const styles = StyleSheet.create({ - entryTextContainer: { - marginRight: 8, - marginBottom: 8, - paddingTop: 6, - paddingBottom: 6, - paddingLeft: 8, - paddingRight: 8, - borderRadius: 4, - }, - entryText: { - fontWeight: 'bold', - textAlign: 'left', - }, - container: { - flexDirection: 'row', - flexWrap: 'wrap', - }, -}); -SquareEnumeratedWords.propTypes = { - entries: PropTypes.arrayOf(PropTypes.string), - contentAlign: PropTypes.string, - appendNumber: PropTypes.bool, -}; - -export default SquareEnumeratedWords; diff --git a/components/SquareEnumeratedWords.tsx b/components/SquareEnumeratedWords.tsx new file mode 100644 index 00000000000..b2667fbbc04 --- /dev/null +++ b/components/SquareEnumeratedWords.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { useTheme } from './themes'; + +type ContentAlignType = 'flex-start' | 'center' | 'flex-end'; +export const SquareEnumeratedWordsContentAlign: Record = Object.freeze({ + left: 'flex-start', + center: 'center', + right: 'flex-end', +}); + +interface SquareEnumeratedWordsProps { + entries: string[]; + appendNumber: boolean; + contentAlign: ContentAlignType; +} + +const SquareEnumeratedWords: React.FC = ({ entries, appendNumber, contentAlign }) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + entryTextContainer: { + backgroundColor: colors.inputBackgroundColor, + }, + entryText: { + color: colors.labelText, + }, + container: { + justifyContent: contentAlign, + }, + }); + + const renderSecret = () => { + const component = []; + const entriesObject = entries.entries(); + for (const [index, secret] of entriesObject) { + if (entries.length > 1) { + const text = appendNumber ? `${index + 1}. ${secret} ` : `${secret} `; + component.push( + + + {text} + + , + ); + } else { + component.push( + + + {secret} + + , + ); + } + } + return component; + }; + + return {renderSecret()}; +}; + +const styles = StyleSheet.create({ + entryTextContainer: { + marginRight: 8, + marginBottom: 8, + paddingTop: 6, + paddingBottom: 6, + paddingLeft: 8, + paddingRight: 8, + borderRadius: 4, + }, + entryText: { + fontWeight: 'bold', + textAlign: 'left', + }, + container: { + flexDirection: 'row', + flexWrap: 'wrap', + }, +}); + +export default SquareEnumeratedWords; diff --git a/components/Tabs.tsx b/components/Tabs.tsx new file mode 100644 index 00000000000..3a382ed4bd5 --- /dev/null +++ b/components/Tabs.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { LayoutChangeEvent, StyleSheet, TouchableOpacity, View } from 'react-native'; +import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated'; + +import { useTheme } from './themes'; + +interface TabProps { + active: boolean; +} + +interface TabsProps { + active: number; + onSwitch: (index: number) => void; + tabs: React.ComponentType[]; + isIpad?: boolean; +} + +export const Tabs: React.FC = ({ active, onSwitch, tabs, isIpad = false }) => { + const { colors } = useTheme(); + const [rootWidth, setRootWidth] = useState(0); + const tabWidth = tabs.length > 0 ? rootWidth / tabs.length : 0; + + const onLayout = (e: LayoutChangeEvent) => setRootWidth(e.nativeEvent.layout.width); + + const underlineStyle = useAnimatedStyle( + () => ({ + transform: [{ translateX: withTiming(active * tabWidth, { duration: 250 }) }], + }), + [active, tabWidth], + ); + + return ( + + {tabs.map((Tab, i) => ( + onSwitch(i)} style={tabsStyles.tabRoot}> + + + ))} + {tabWidth > 0 && ( + + )} + + ); +}; + +const tabsStyles = StyleSheet.create({ + root: { + flexDirection: 'row', + height: 50, + }, + tabRoot: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + underline: { + position: 'absolute', + bottom: 0, + left: 0, + height: 2, + }, + marginBottom: { + marginBottom: 30, + }, +}); diff --git a/components/TipBox.tsx b/components/TipBox.tsx new file mode 100644 index 00000000000..3911e1cd73f --- /dev/null +++ b/components/TipBox.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { View, StyleSheet, ViewStyle } from 'react-native'; +import { useTheme } from './themes'; +import BlueText from './BlueText'; +interface TipBoxProps { + number?: string; + title?: string; + description?: string; + additionalDescription?: string; + containerStyle?: ViewStyle; +} + +const TipBox: React.FC = ({ number, title, description, additionalDescription, containerStyle }) => { + const { colors } = useTheme(); + const stylesHook = StyleSheet.create({ + tipBox: { + backgroundColor: colors.ballOutgoingExpired, + borderRadius: 12, + padding: 16, + marginBottom: 24, + ...containerStyle, + }, + tipHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: number || title ? 16 : 0, + }, + tipHeaderText: { + marginLeft: 4, + flex: 1, + }, + description: { + marginBottom: additionalDescription ? 16 : 0, + }, + }); + + return ( + + {(number || title) && ( + + {number && ( + + {number} + + )} + {title && ( + + {title} + + )} + + )} + {description && {description}} + {additionalDescription && {additionalDescription}} + + ); +}; + +const styles = StyleSheet.create({ + vaultKeyCircle: { + width: 32, + height: 32, + justifyContent: 'center', + alignItems: 'center', + }, + vaultKeyText: { + fontSize: 18, + fontWeight: 'bold', + }, +}); + +export default TipBox; diff --git a/components/TooltipMenu.android.js b/components/TooltipMenu.android.js deleted file mode 100644 index 8b83f3e30c8..00000000000 --- a/components/TooltipMenu.android.js +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useRef, useEffect, forwardRef } from 'react'; -import PropTypes from 'prop-types'; -import { TouchableOpacity } from 'react-native'; -import showPopupMenu from '../blue_modules/showPopupMenu'; - -const ToolTipMenu = (props, ref) => { - const menuRef = useRef(); - const disabled = props.disabled ?? false; - const isMenuPrimaryAction = props.isMenuPrimaryAction ?? false; - - const buttonStyle = props.buttonStyle ?? {}; - const handleToolTipSelection = selection => { - props.onPressMenuItem(selection.id); - }; - - useEffect(() => { - if (ref && ref.current) { - ref.current.dismissMenu = dismissMenu; - } - }, [ref]); - - const dismissMenu = () => { - console.log('dismissMenu Not implemented'); - }; - - const showMenu = () => { - const menu = []; - for (const actions of props.actions) { - if (Array.isArray(actions)) { - for (const actionToMap of actions) { - menu.push({ id: actionToMap.id, label: actionToMap.text }); - } - } else { - menu.push({ id: actions.id, label: actions.text }); - } - } - - showPopupMenu(menu, handleToolTipSelection, menuRef.current); - }; - - return ( - - {props.children} - - ); -}; - -export default forwardRef(ToolTipMenu); -ToolTipMenu.propTypes = { - actions: PropTypes.object.isRequired, - children: PropTypes.node.isRequired, - onPressMenuItem: PropTypes.func.isRequired, - isMenuPrimaryAction: PropTypes.bool, - onPress: PropTypes.func, - disabled: PropTypes.bool, -}; diff --git a/components/TooltipMenu.helpers.ts b/components/TooltipMenu.helpers.ts new file mode 100644 index 00000000000..be8bb70634d --- /dev/null +++ b/components/TooltipMenu.helpers.ts @@ -0,0 +1,115 @@ +import { ContextMenuAction } from 'react-native-context-menu-view'; +import { Action } from './types'; + +export type Platform = 'ios' | 'android'; + +// Mirrors the structure of the items we hand to the native ContextMenu, with +// the original Action.id at every node. We walk this in lockstep with the +// indexPath the native side returns on press, so two actions sharing the same +// visible text (think localized "Copy") cannot collide. +export type IdNode = { + id?: string; + children?: IdNode[]; +}; + +export const normalizeMenuState = (menuState?: Action['menuState']): boolean | undefined => { + if (menuState === undefined) return undefined; + return menuState === 'mixed' ? true : Boolean(menuState); +}; + +const mapLeaf = (action: Action, platform: Platform): { item: ContextMenuAction; idNode: IdNode } | null => { + if (!action?.id || action.hidden) return null; + + const subResults = (action.subactions ?? []) + .map(sub => mapLeaf(sub, platform)) + .filter((r): r is { item: ContextMenuAction; idNode: IdNode } => r !== null); + + const item: ContextMenuAction = { + title: action.text, + subtitle: action.subtitle, + systemIcon: platform === 'ios' ? (action.icon?.iconValue ?? action.image) : undefined, + icon: platform === 'android' ? (action.icon?.iconValue ?? action.image) : undefined, + iconColor: typeof action.imageColor === 'string' ? action.imageColor : undefined, + destructive: Boolean(action.destructive), + disabled: Boolean(action.disabled), + inlineChildren: platform === 'ios' ? action.displayInline : undefined, + }; + + const selected = normalizeMenuState(action.menuState); + if (selected !== undefined) item.selected = selected; + if (subResults.length > 0) item.actions = subResults.map(r => r.item); + + const idNode: IdNode = { id: String(action.id) }; + if (subResults.length > 0) idNode.children = subResults.map(r => r.idNode); + + return { item, idNode }; +}; + +/** + * Build the items array passed to the native ContextMenu and a parallel + * id-tree of the exact same shape. Returning both in a single pass guarantees + * they cannot drift apart. + * + * iOS preserves grouping (with synthetic inline parents); Android flattens + * because its native menu doesn't render inline groups. + */ +export const buildMenu = (actions: Action[] | Action[][], platform: Platform): { items: ContextMenuAction[]; ids: IdNode[] } => { + const items: ContextMenuAction[] = []; + const ids: IdNode[] = []; + + if (platform === 'ios') { + for (const group of actions) { + if (Array.isArray(group)) { + const inline = group.map(a => mapLeaf(a, platform)).filter((r): r is { item: ContextMenuAction; idNode: IdNode } => r !== null); + if (inline.length === 0) continue; + items.push({ + title: '', + actions: inline.map(r => r.item), + inlineChildren: true, + } as ContextMenuAction); + // Synthetic inline group: no id of its own, only carries children. + ids.push({ children: inline.map(r => r.idNode) }); + } else { + const r = mapLeaf(group, platform); + if (r) { + items.push(r.item); + ids.push(r.idNode); + } + } + } + return { items, ids }; + } + + for (const action of actions.flat()) { + const r = mapLeaf(action, platform); + if (r) { + items.push(r.item); + ids.push(r.idNode); + } + } + return { items, ids }; +}; + +/** + * Resolve the original Action.id for a press event by walking the id-tree + * with the path delivered by the native side. + * + * Path semantics: + * - iOS: indexPath is always populated, full path from root. + * - Android top-level: only `index` is set; we synthesize `[index]`. + * - Android submenu: indexPath is `[parentIndex, childIndex]`. + */ +export const lookupId = (ids: IdNode[], path: readonly number[]): string | undefined => { + if (path.length === 0) return undefined; + + let nodes: IdNode[] | undefined = ids; + let node: IdNode | undefined; + + for (const i of path) { + if (!nodes || i < 0 || i >= nodes.length) return undefined; + node = nodes[i]; + nodes = node.children; + } + + return node?.id; +}; diff --git a/components/TooltipMenu.ios.js b/components/TooltipMenu.ios.js deleted file mode 100644 index caf51d2fb2b..00000000000 --- a/components/TooltipMenu.ios.js +++ /dev/null @@ -1,114 +0,0 @@ -import React, { forwardRef } from 'react'; -import { ContextMenuView, ContextMenuButton } from 'react-native-ios-context-menu'; -import PropTypes from 'prop-types'; -import QRCodeComponent from './QRCodeComponent'; -import { TouchableOpacity } from 'react-native'; - -const ToolTipMenu = (props, ref) => { - const menuItemMapped = ({ action, menuOptions }) => { - const item = { - actionKey: action.id, - actionTitle: action.text, - icon: action.icon, - menuOptions, - menuTitle: action.menuTitle, - }; - item.menuState = action.menuStateOn ? 'on' : 'off'; - - if (action.disabled) { - item.menuAttributes = ['disabled']; - } - return item; - }; - - const menuItems = props.actions.map(action => { - if (Array.isArray(action)) { - const mapped = []; - for (const actionToMap of action) { - mapped.push(menuItemMapped({ action: actionToMap })); - } - const submenu = { - menuOptions: ['displayInline'], - menuItems: mapped, - menuTitle: '', - }; - return submenu; - } else { - return menuItemMapped({ action }); - } - }); - const menuTitle = props.title ?? ''; - const isButton = !!props.isButton; - const isMenuPrimaryAction = props.isMenuPrimaryAction ? props.isMenuPrimaryAction : false; - const previewQRCode = props.previewQRCode ?? false; - const previewValue = props.previewValue; - const disabled = props.disabled ?? false; - - const buttonStyle = props.buttonStyle; - return isButton ? ( - { - props.onPressMenuItem(nativeEvent.actionKey); - }} - isMenuPrimaryAction={isMenuPrimaryAction} - menuConfig={{ - menuTitle, - menuItems, - }} - style={buttonStyle} - > - {props.onPress ? ( - - {props.children} - - ) : ( - props.children - )} - - ) : ( - { - props.onPressMenuItem(nativeEvent.actionKey); - }} - menuConfig={{ - menuTitle, - menuItems, - }} - {...(previewQRCode - ? { - previewConfig: { - previewType: 'CUSTOM', - backgroundColor: 'white', - }, - renderPreview: () => , - } - : {})} - > - {props.onPress ? ( - - {props.children} - - ) : ( - props.children - )} - - ); -}; - -export default forwardRef(ToolTipMenu); -ToolTipMenu.propTypes = { - actions: PropTypes.object.isRequired, - title: PropTypes.string, - children: PropTypes.node.isRequired, - onPressMenuItem: PropTypes.func.isRequired, - isMenuPrimaryAction: PropTypes.bool, - isButton: PropTypes.bool, - previewQRCode: PropTypes.bool, - onPress: PropTypes.func, - previewValue: PropTypes.string, - disabled: PropTypes.bool, -}; diff --git a/components/TooltipMenu.js b/components/TooltipMenu.js deleted file mode 100644 index e57a12552f3..00000000000 --- a/components/TooltipMenu.js +++ /dev/null @@ -1,5 +0,0 @@ -const ToolTipMenu = props => { - return props.children; -}; - -export default ToolTipMenu; diff --git a/components/TooltipMenu.tsx b/components/TooltipMenu.tsx new file mode 100644 index 00000000000..89fddef03eb --- /dev/null +++ b/components/TooltipMenu.tsx @@ -0,0 +1,139 @@ +import React, { useCallback, useMemo } from 'react'; +import { NativeSyntheticEvent, Platform, Pressable, StyleProp, StyleSheet, View, ViewStyle } from 'react-native'; +import ContextMenu, { ContextMenuOnPressNativeEvent } from 'react-native-context-menu-view'; +import { ToolTipMenuProps } from './types'; +import { useSettings } from '../hooks/context/useSettings'; +import { buildMenu, lookupId } from './TooltipMenu.helpers'; + +const ToolTipMenu = (props: ToolTipMenuProps) => { + const { + title = '', + shouldOpenOnLongPress = true, + disabled = false, + onPress, + isButton = false, + buttonStyle, + onPressMenuItem, + children, + actions, + accessibilityLabel, + accessibilityHint, + accessibilityRole, + accessibilityState, + testID, + style, + enableAndroidRipple = true, + } = props; + + const { language } = useSettings(); + + const { items, ids } = useMemo(() => buildMenu(actions, Platform.OS as 'ios' | 'android'), [actions]); + + const handlePressMenuItem = useCallback( + (e: NativeSyntheticEvent) => { + const { name, indexPath, index } = e.nativeEvent; + const path = indexPath?.length ? indexPath : typeof index === 'number' ? [index] : []; + const id = lookupId(ids, path); + if (id !== undefined) onPressMenuItem(id); + else if (name) onPressMenuItem(name); // last-resort fallback + }, + [ids, onPressMenuItem], + ); + + if (disabled || actions.length === 0) return null; + + // The native ContextMenu is the single source of truth for opening the menu: + // - Android: ContextMenuView's GestureDetector handles tap (dropdown mode) + // and long-press, then opens the popup itself. + // - iOS: UIContextMenuInteraction is attached to the first React child, with + // `showsMenuAsPrimaryAction` for tap-to-open in dropdown mode. + // + // We wrap in a Pressable ONLY when the caller wants a separate `onPress` + // (a short-tap action that does something OTHER than open the menu). Adding + // any extra Pressable handler is unnecessary and on Android races with the + // native gesture detector — usePressability always returns true from + // onStartShouldSetResponder, so the JS responder system claims the touch + // and dispatches ACTION_CANCEL to the child native view, leaving the menu + // unopened. There is no escape hatch for that — Pressable cannot be + // configured to skip responder claiming. + // + // Trade-off: dropdown buttons without onPress (HeaderMenuButton et al.) + // get no Android ripple. The menu opening (≈100ms) is the feedback. We + // accept this rather than reintroduce the gesture-cancel race. + const wrapInPressable = Boolean(onPress); + + const buttonShellStyle: StyleProp = isButton ? styles.button : undefined; + const visibleStyle = StyleSheet.flatten([buttonShellStyle, style, buttonStyle]); + + const menu = ( + + {children} + + ); + + if (!wrapInPressable) { + // Wrap the native ContextMenu in a plain View that carries `testID` and the + // accessibility props. On iOS, react-native-context-menu-view propagates + // the accessibility identifier across multiple descendants of its native + // host, so attaching `testID` directly to ContextMenu makes Detox match + // multiple views (`Multiple elements found for "MATCHER(id == ...)"`). + // A plain View gives Detox a single, deterministic match and—unlike + // Pressable—never claims the JS responder, so it does not reintroduce the + // Android gesture-cancel race documented above. + return ( + + + {children} + + + ); + } + + return ( + + StyleSheet.flatten([visibleStyle, pressed && enableAndroidRipple && Platform.OS === 'android' ? styles.pressed : null]) + } + accessibilityLabel={accessibilityLabel} + accessibilityHint={accessibilityHint} + accessibilityRole={accessibilityRole} + accessibilityState={accessibilityState} + accessibilityLanguage={language} + testID={testID} + hitSlop={8} + > + {menu} + + ); +}; + +export default ToolTipMenu; + +const styles = StyleSheet.create({ + button: { alignSelf: 'center' }, + menuFlex: { flex: 1 }, + pressed: { opacity: 0.6 }, +}); diff --git a/components/TotalWalletsBalance.tsx b/components/TotalWalletsBalance.tsx new file mode 100644 index 00000000000..eea695c4764 --- /dev/null +++ b/components/TotalWalletsBalance.tsx @@ -0,0 +1,165 @@ +import React, { useMemo, useCallback } from 'react'; +import { TouchableOpacity, Text, StyleSheet, View, useWindowDimensions } from 'react-native'; +import { useStorage } from '../hooks/context/useStorage'; +import loc, { formatBalanceWithoutSuffix } from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import ToolTipMenu from './TooltipMenu'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import { useSettings } from '../hooks/context/useSettings'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { useTheme } from './themes'; + +export const TotalWalletsBalancePreferredUnit = 'TotalWalletsBalancePreferredUnit'; +export const TotalWalletsBalanceKey = 'TotalWalletsBalance'; + +const TotalWalletsBalance: React.FC = React.memo(() => { + const { wallets } = useStorage(); + const { + preferredFiatCurrency, + isTotalBalanceEnabled, + setIsTotalBalanceEnabledStorage, + totalBalancePreferredUnit, + setTotalBalancePreferredUnitStorage, + } = useSettings(); + const { colors } = useTheme(); + const { fontScale } = useWindowDimensions(); + + const totalBalanceFormatted = useMemo(() => { + const totalBalance = wallets.reduce((prev, curr) => { + return curr.hideBalance ? prev : prev + (curr.getBalance() || 0); + }, 0); + return formatBalanceWithoutSuffix(totalBalance, totalBalancePreferredUnit, true); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallets, totalBalancePreferredUnit, preferredFiatCurrency]); + + const scaledStyles = useMemo( + () => ({ + container: { + paddingVertical: Math.round(8 * fontScale), + }, + label: { + lineHeight: Math.round(18 * fontScale), + marginBottom: Math.round(2 * fontScale), + }, + balance: { + lineHeight: Math.round(38 * Math.max(1, fontScale)), + }, + }), + [fontScale], + ); + + const toolTipActions = useMemo( + () => [ + { + id: 'viewInActions', + text: '', + displayInline: true, + subactions: [ + { + ...CommonToolTipActions.ViewInFiat, + text: loc.formatString(loc.total_balance_view.display_in_fiat, { currency: preferredFiatCurrency.endPointKey }), + hidden: totalBalancePreferredUnit === BitcoinUnit.LOCAL_CURRENCY, + }, + { ...CommonToolTipActions.ViewInSats, hidden: totalBalancePreferredUnit === BitcoinUnit.SATS }, + { ...CommonToolTipActions.ViewInBitcoin, hidden: totalBalancePreferredUnit === BitcoinUnit.BTC }, + ], + }, + CommonToolTipActions.CopyAmount, + CommonToolTipActions.Hide, + ], + [preferredFiatCurrency, totalBalancePreferredUnit], + ); + + const onPressMenuItem = useCallback( + async (id: string) => { + switch (id) { + case CommonToolTipActions.ViewInFiat.id: + await setTotalBalancePreferredUnitStorage(BitcoinUnit.LOCAL_CURRENCY); + break; + case CommonToolTipActions.ViewInSats.id: + await setTotalBalancePreferredUnitStorage(BitcoinUnit.SATS); + break; + case CommonToolTipActions.ViewInBitcoin.id: + await setTotalBalancePreferredUnitStorage(BitcoinUnit.BTC); + break; + case CommonToolTipActions.Hide.id: + await setIsTotalBalanceEnabledStorage(false); + break; + case CommonToolTipActions.CopyAmount.id: + Clipboard.setString(totalBalanceFormatted.toString()); + break; + default: + break; + } + }, + [setIsTotalBalanceEnabledStorage, totalBalanceFormatted, setTotalBalancePreferredUnitStorage], + ); + + const handleBalanceOnPress = useCallback(async () => { + const nextUnit = + totalBalancePreferredUnit === BitcoinUnit.BTC + ? BitcoinUnit.SATS + : totalBalancePreferredUnit === BitcoinUnit.SATS + ? BitcoinUnit.LOCAL_CURRENCY + : BitcoinUnit.BTC; + await setTotalBalancePreferredUnitStorage(nextUnit); + }, [totalBalancePreferredUnit, setTotalBalancePreferredUnitStorage]); + + if (!isTotalBalanceEnabled) return null; + + return ( + + + + {loc.wallets.total_balance} + + + + {totalBalanceFormatted} + {totalBalancePreferredUnit !== BitcoinUnit.LOCAL_CURRENCY && ( + {` ${totalBalancePreferredUnit}`} + )} + + + + + ); +}); + +const styles = StyleSheet.create({ + menuContainer: { + alignSelf: 'stretch', + }, + container: { + flexDirection: 'column', + alignItems: 'flex-start', + paddingHorizontal: 16, + paddingVertical: 8, + width: '100%', + }, + balanceTouchable: { + alignSelf: 'stretch', + width: '100%', + }, + label: { + fontSize: 14, + marginBottom: 2, + color: '#9BA0A9', + }, + balance: { + fontSize: 32, + fontWeight: 'bold', + lineHeight: 38, + }, + currency: { + fontSize: 18, + fontWeight: 'bold', + }, +}); + +export default TotalWalletsBalance; diff --git a/components/TransactionListItem.js b/components/TransactionListItem.js deleted file mode 100644 index faf79b1c0a4..00000000000 --- a/components/TransactionListItem.js +++ /dev/null @@ -1,378 +0,0 @@ -/* eslint react/prop-types: "off" */ -import React, { useState, useMemo, useCallback, useContext, useEffect, useRef } from 'react'; -import { Linking, StyleSheet, View } from 'react-native'; -import Clipboard from '@react-native-clipboard/clipboard'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { useNavigation, useTheme } from '@react-navigation/native'; - -import { BitcoinUnit } from '../models/bitcoinUnits'; -import * as NavigationService from '../NavigationService'; -import loc, { formatBalanceWithoutSuffix, transactionTimeToReadable } from '../loc'; -import Lnurl from '../class/lnurl'; -import { BlueStorageContext } from '../blue_modules/storage-context'; -import ToolTipMenu from './TooltipMenu'; -import { BlueListItem } from '../BlueComponents'; -import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon'; -import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon'; -import TransactionOffchainIcon from '../components/icons/TransactionOffchainIcon'; -import TransactionOffchainIncomingIcon from '../components/icons/TransactionOffchainIncomingIcon'; -import TransactionOnchainIcon from '../components/icons/TransactionOnchainIcon'; -import TransactionOutgoingIcon from '../components/icons/TransactionOutgoingIcon'; -import TransactionPendingIcon from '../components/icons/TransactionPendingIcon'; - -export const TransactionListItem = React.memo(({ item, itemPriceUnit = BitcoinUnit.BTC, walletID }) => { - const [subtitleNumberOfLines, setSubtitleNumberOfLines] = useState(1); - const { colors } = useTheme(); - const { navigate } = useNavigation(); - const menuRef = useRef(); - const { txMetadata, wallets, preferredFiatCurrency, language } = useContext(BlueStorageContext); - const containerStyle = useMemo( - () => ({ - backgroundColor: 'transparent', - borderBottomColor: colors.lightBorder, - paddingTop: 16, - paddingBottom: 16, - paddingRight: 0, - }), - [colors.lightBorder], - ); - - const title = useMemo(() => { - if (item.confirmations === 0) { - return loc.transactions.pending; - } else { - return transactionTimeToReadable(item.received); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.confirmations, item.received, language]); - const txMemo = txMetadata[item.hash]?.memo ?? ''; - const subtitle = useMemo(() => { - let sub = item.confirmations < 7 ? loc.formatString(loc.transactions.list_conf, { number: item.confirmations }) : ''; - if (sub !== '') sub += ' '; - sub += txMemo; - if (item.memo) sub += item.memo; - return sub || null; - }, [txMemo, item.confirmations, item.memo]); - - const rowTitle = useMemo(() => { - if (item.type === 'user_invoice' || item.type === 'payment_request') { - if (isNaN(item.value)) { - item.value = '0'; - } - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp + item.expire_time; - - if (invoiceExpiration > now) { - return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); - } else if (invoiceExpiration < now) { - if (item.ispaid) { - return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); - } else { - return loc.lnd.expired; - } - } - } else { - return formatBalanceWithoutSuffix(item.value && item.value, itemPriceUnit, true).toString(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item, itemPriceUnit, preferredFiatCurrency]); - - const rowTitleStyle = useMemo(() => { - let color = colors.successColor; - - if (item.type === 'user_invoice' || item.type === 'payment_request') { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp + item.expire_time; - - if (invoiceExpiration > now) { - color = colors.successColor; - } else if (invoiceExpiration < now) { - if (item.ispaid) { - color = colors.successColor; - } else { - color = '#9AA0AA'; - } - } - } else if (item.value / 100000000 < 0) { - color = colors.foregroundColor; - } - - return { - color, - fontSize: 14, - fontWeight: '600', - textAlign: 'right', - width: 96, - }; - }, [item, colors.foregroundColor, colors.successColor]); - - const avatar = useMemo(() => { - // is it lightning refill tx? - if (item.category === 'receive' && item.confirmations < 3) { - return ( - - - - ); - } - - if (item.type && item.type === 'bitcoind_tx') { - return ( - - - - ); - } - if (item.type === 'paid_invoice') { - // is it lightning offchain payment? - return ( - - - - ); - } - - if (item.type === 'user_invoice' || item.type === 'payment_request') { - if (!item.ispaid) { - const currentDate = new Date(); - const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise - const invoiceExpiration = item.timestamp + item.expire_time; - if (invoiceExpiration < now) { - return ( - - - - ); - } - } else { - return ( - - - - ); - } - } - - if (!item.confirmations) { - return ( - - - - ); - } else if (item.value < 0) { - return ( - - - - ); - } else { - return ( - - - - ); - } - }, [item]); - - useEffect(() => { - setSubtitleNumberOfLines(1); - }, [subtitle]); - - const onPress = useCallback(async () => { - menuRef?.current?.dismissMenu(); - if (item.hash) { - navigate('TransactionStatus', { hash: item.hash, walletID }); - } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice') { - const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID); - if (lightningWallet.length === 1) { - try { - // is it a successful lnurl-pay? - const LN = new Lnurl(false, AsyncStorage); - let paymentHash = item.payment_hash; - if (typeof paymentHash === 'object') { - paymentHash = Buffer.from(paymentHash.data).toString('hex'); - } - const loaded = await LN.loadSuccessfulPayment(paymentHash); - if (loaded) { - NavigationService.navigate('ScanLndInvoiceRoot', { - screen: 'LnurlPaySuccess', - params: { - paymentHash, - justPaid: false, - fromWalletID: lightningWallet[0].getID(), - }, - }); - return; - } - } catch (e) { - console.log(e); - } - - navigate('LNDViewInvoice', { - invoice: item, - walletID: lightningWallet[0].getID(), - }); - } - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item, wallets]); - - const handleOnExpandNote = useCallback(() => { - setSubtitleNumberOfLines(0); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [subtitle]); - - const subtitleProps = useMemo(() => ({ numberOfLines: subtitleNumberOfLines }), [subtitleNumberOfLines]); - - const handleOnCopyAmountTap = useCallback(() => Clipboard.setString(rowTitle.replace(/[\s\\-]/g, '')), [rowTitle]); - const handleOnCopyTransactionID = useCallback(() => Clipboard.setString(item.hash), [item.hash]); - const handleOnCopyNote = useCallback(() => Clipboard.setString(subtitle), [subtitle]); - const handleOnViewOnBlockExplorer = useCallback(() => { - const url = `https://mempool.space/tx/${item.hash}`; - Linking.canOpenURL(url).then(supported => { - if (supported) { - Linking.openURL(url); - } - }); - }, [item.hash]); - const handleCopyOpenInBlockExplorerPress = useCallback(() => { - Clipboard.setString(`https://mempool.space/tx/${item.hash}`); - }, [item.hash]); - - const onToolTipPress = useCallback( - id => { - if (id === TransactionListItem.actionKeys.CopyAmount) { - handleOnCopyAmountTap(); - } else if (id === TransactionListItem.actionKeys.CopyNote) { - handleOnCopyNote(); - } else if (id === TransactionListItem.actionKeys.OpenInBlockExplorer) { - handleOnViewOnBlockExplorer(); - } else if (id === TransactionListItem.actionKeys.ExpandNote) { - handleOnExpandNote(); - } else if (id === TransactionListItem.actionKeys.CopyBlockExplorerLink) { - handleCopyOpenInBlockExplorerPress(); - } else if (id === TransactionListItem.actionKeys.CopyTXID) { - handleOnCopyTransactionID(); - } - }, - [ - handleCopyOpenInBlockExplorerPress, - handleOnCopyAmountTap, - handleOnCopyNote, - handleOnCopyTransactionID, - handleOnExpandNote, - handleOnViewOnBlockExplorer, - ], - ); - - const toolTipActions = useMemo(() => { - const actions = []; - if (rowTitle !== loc.lnd.expired) { - actions.push({ - id: TransactionListItem.actionKeys.CopyAmount, - text: loc.transactions.details_copy_amount, - icon: TransactionListItem.actionIcons.Clipboard, - }); - } - - if (subtitle) { - actions.push({ - id: TransactionListItem.actionKeys.CopyNote, - text: loc.transactions.details_copy_note, - icon: TransactionListItem.actionIcons.Clipboard, - }); - } - if (item.hash) { - actions.push( - { - id: TransactionListItem.actionKeys.CopyTXID, - text: loc.transactions.details_copy_txid, - icon: TransactionListItem.actionIcons.Clipboard, - }, - { - id: TransactionListItem.actionKeys.CopyBlockExplorerLink, - text: loc.transactions.details_copy_block_explorer_link, - icon: TransactionListItem.actionIcons.Clipboard, - }, - [ - { - id: TransactionListItem.actionKeys.OpenInBlockExplorer, - text: loc.transactions.details_show_in_block_explorer, - icon: TransactionListItem.actionIcons.Link, - }, - ], - ); - } - - if (subtitle && subtitleNumberOfLines === 1) { - actions.push([ - { - id: TransactionListItem.actionKeys.ExpandNote, - text: loc.transactions.expand_note, - icon: TransactionListItem.actionIcons.Note, - }, - ]); - } - - return actions; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [item.hash, subtitle, rowTitle, subtitleNumberOfLines, txMetadata]); - - return ( - - - - - - ); -}); - -TransactionListItem.actionKeys = { - CopyTXID: 'copyTX_ID', - CopyBlockExplorerLink: 'copy_blockExplorer', - ExpandNote: 'expandNote', - OpenInBlockExplorer: 'open_in_blockExplorer', - CopyAmount: 'copyAmount', - CopyNote: 'copyNote', -}; - -TransactionListItem.actionIcons = { - Eye: { - iconType: 'SYSTEM', - iconValue: 'eye', - }, - EyeSlash: { - iconType: 'SYSTEM', - iconValue: 'eye.slash', - }, - Clipboard: { - iconType: 'SYSTEM', - iconValue: 'doc.on.doc', - }, - Link: { - iconType: 'SYSTEM', - iconValue: 'link', - }, - Note: { - iconType: 'SYSTEM', - iconValue: 'note.text', - }, -}; - -const styles = StyleSheet.create({ - iconWidth: { width: 25 }, - container: { marginHorizontal: 4 }, -}); diff --git a/components/TransactionListItem.tsx b/components/TransactionListItem.tsx new file mode 100644 index 00000000000..2bf08a1ad17 --- /dev/null +++ b/components/TransactionListItem.tsx @@ -0,0 +1,583 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { memo, useCallback, useMemo, useRef } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { Animated, Easing, Linking, Pressable, Text, TextStyle, ViewStyle, StyleSheet, View, useWindowDimensions } from 'react-native'; +import Lnurl from '../class/lnurl'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import { LightningTransaction, Transaction } from '../class/wallets/types'; +import TransactionExpiredIcon from '../components/icons/TransactionExpiredIcon'; +import TransactionIncomingIcon from '../components/icons/TransactionIncomingIcon'; +import TransactionOffchainIcon from '../components/icons/TransactionOffchainIcon'; +import TransactionOffchainIncomingIcon from '../components/icons/TransactionOffchainIncomingIcon'; +import TransactionOnchainIcon from '../components/icons/TransactionOnchainIcon'; +import TransactionOutgoingIcon from '../components/icons/TransactionOutgoingIcon'; +import TransactionPendingIcon from '../components/icons/TransactionPendingIcon'; +import loc, { formatBalanceWithoutSuffix, formatTransactionListDate, transactionTimeToReadable } from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import { useSettings } from '../hooks/context/useSettings'; +import { useTheme } from './themes'; +import { Action } from './types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { DetailViewStackParamList } from '../navigation/DetailViewStackParamList'; +import { useStorage } from '../hooks/context/useStorage'; +import ToolTipMenu from './TooltipMenu'; +import { CommonToolTipActions } from '../typings/CommonToolTipActions'; +import { pop } from '../NavigationService'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { uint8ArrayToHex } from '../blue_modules/uint8array-extras'; +import ListItem from './ListItem'; + +const styles = StyleSheet.create({ + fullWidthButton: { + width: '100%', + alignSelf: 'stretch', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }, + avatarContainer: { + marginRight: 12, + alignItems: 'center', + justifyContent: 'center', + }, + textContainer: { + flex: 1, + paddingRight: 8, + }, + title: { + fontSize: 16, + fontWeight: '500', + }, + subtitle: { + fontSize: 14, + lineHeight: 20, + }, + rightColumn: { + marginLeft: 8, + alignItems: 'flex-end', + justifyContent: 'center', + }, + rightTitle: { + textAlign: 'right', + }, + animatedScaleContainer: { + width: '100%', + }, +}); + +type AnimatedPressableRowProps = { + onPress: () => void; + children: React.ReactNode; + accessibilityLabel: string; +}; + +const AnimatedPressableRow: React.FC = ({ onPress, children, accessibilityLabel }) => { + const scaleAnim = useRef(new Animated.Value(1)).current; + + const animateTo = useCallback( + (toValue: number) => { + Animated.timing(scaleAnim, { + toValue, + duration: 120, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(); + }, + [scaleAnim], + ); + + return ( + animateTo(0.97)} + onPressOut={() => animateTo(1)} + accessibilityRole="button" + accessibilityLabel={accessibilityLabel} + > + {children} + + ); +}; + +interface TransactionListItemProps { + itemPriceUnit: BitcoinUnit; + walletID: string; + item: Transaction & LightningTransaction; // using type intersection to have less issues with ts + searchQuery?: string; + style?: ViewStyle; + renderHighlightedText?: (text: string, query: string) => React.ReactElement; + onPress?: () => void; + disableNavigation?: boolean; +} + +type NavigationProps = NativeStackNavigationProp; + +const TransactionListItemComponent: React.FC = ({ + item, + itemPriceUnit, + walletID, + searchQuery, + style, + renderHighlightedText, + onPress: customOnPress, + disableNavigation = false, +}: TransactionListItemProps) => { + const { colors } = useTheme(); + const { navigate } = useNavigation(); + const { txMetadata, counterpartyMetadata, wallets } = useStorage(); + const { language, selectedBlockExplorer } = useSettings(); + const insets = useSafeAreaInsets(); + const { fontScale } = useWindowDimensions(); + const containerStyle = useMemo( + () => ({ + backgroundColor: colors.background, + borderBottomColor: colors.lightBorder, + }), + [colors.background, colors.lightBorder], + ); + + const combinedStyle = useMemo(() => [containerStyle, style], [containerStyle, style]); + + const shortenContactName = (name: string): string => { + if (name.length < 16) return name; + return name.substr(0, 7) + '...' + name.substr(name.length - 7, 7); + }; + + let counterparty; + if (item.counterparty) { + counterparty = counterpartyMetadata?.[item.counterparty]?.label ?? item.counterparty; + } + const txMemo = (counterparty ? `[${shortenContactName(counterparty)}] ` : '') + (txMetadata[item.hash]?.memo ?? ''); + const noteForCopy = (txMemo || item.memo || '').trim() || undefined; + + // For LightningArkWallet rows, prepend a kind tag to the date subtitle. Such a + // wallet transacts entirely via Boltz swaps, so every row is Lightning; the + // only genuinely on-chain activity is onboarding/refill (boarding UTXOs), + // tagged from the synthetic `boarding-…` txid set in + // lightning-ark-wallet.getTransactions(). Other wallet types are unaffected. + const arkRowKind = useMemo<'Lightning' | 'Refill' | undefined>(() => { + const wallet = wallets.find(w => w.getID() === item.walletID); + if (wallet?.type !== LightningArkWallet.type) return undefined; + const txid = (item as { txid?: string }).txid; + if (txid?.startsWith('boarding-')) return 'Refill'; + return 'Lightning'; + }, [item, wallets]); + + // A refill is "Pending" until the SDK settles its boarding UTXO into a VTXO + // (also when it enters the spendable balance). getTransactions() pass 2 tags + // those not-yet-settled rows with a `boarding-utxo-…` id; settled refills use + // `boarding-…` and render as a normal confirmed receive. + const isPendingRefill = useMemo( + () => arkRowKind === 'Refill' && !!(item as { txid?: string }).txid?.startsWith('boarding-utxo-'), + [arkRowKind, item], + ); + + const listTitleKey = useMemo((): 'pending' | 'sent' | 'received' => { + if (isPendingRefill) return 'pending'; + if (item.category === 'receive' && item.confirmations! < 3) return 'pending'; + if (item.type === 'bitcoind_tx') return item.value! < 0 ? 'sent' : 'received'; + if (item.type === 'paid_invoice') return 'sent'; + if (item.type === 'user_invoice' || item.type === 'payment_request') { + if (!item.ispaid) return 'pending'; + return 'received'; + } + if (!item.confirmations) return 'pending'; + return item.value! < 0 ? 'sent' : 'received'; + }, [isPendingRefill, item.category, item.confirmations, item.type, item.value, item.ispaid]); + + const listTitle = useMemo(() => { + if (listTitleKey === 'pending') return loc.transactions.pending; + if (listTitleKey === 'sent') return loc.transactions.list_title_sent; + return loc.transactions.list_title_received; + }, [listTitleKey]); + + const isPending = listTitleKey === 'pending'; + + const dateLine = useMemo(() => { + const formatted = isPending ? transactionTimeToReadable(item.timestamp) : formatTransactionListDate(item.timestamp * 1000); + return arkRowKind ? `${arkRowKind} · ${formatted}` : formatted; + // language in deps so date format updates when locale changes (formatters use global locale) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isPending, item.timestamp, language, arkRowKind]); + + const formattedAmount = useMemo(() => { + return formatBalanceWithoutSuffix(item.value, itemPriceUnit, true).toString(); + }, [item.value, itemPriceUnit]); + + const rowTitle = useMemo(() => { + if (item.type === 'user_invoice' || item.type === 'payment_request') { + const currentDate = new Date(); + const now = Math.floor(currentDate.getTime() / 1000); + const invoiceExpiration = item.timestamp! + item.expire_time!; + if (invoiceExpiration > now || item.ispaid) { + return formattedAmount; + } else { + return loc.lnd.expired; + } + } + return formattedAmount; + }, [item, formattedAmount]); + + const rowTitleStyle = useMemo(() => { + let color = colors.successColor; + + if (item.type === 'user_invoice' || item.type === 'payment_request') { + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise + const invoiceExpiration = item.timestamp! + item.expire_time!; + + if (invoiceExpiration > now) { + color = colors.successColor; + } else if (invoiceExpiration < now) { + if (item.ispaid) { + color = colors.successColor; + } else { + color = '#9AA0AA'; + } + } + } else if (item.value! / 100000000 < 0) { + color = colors.foregroundColor; + } + + return { + color, + fontSize: 14, + fontWeight: '600' as TextStyle['fontWeight'], + lineHeight: Math.round(20 * fontScale), + textAlign: 'right', + paddingRight: insets.right, + paddingLeft: insets.left, + } as TextStyle; + }, [ + colors.successColor, + colors.foregroundColor, + item.type, + item.value, + item.timestamp, + item.expire_time, + item.ispaid, + insets.right, + insets.left, + fontScale, + ]); + + const determineTransactionTypeAndAvatar = () => { + // A refill awaiting settlement: show it as pending, not as a completed receive. + if (isPendingRefill) { + return { + label: loc.transactions.pending_transaction, + icon: , + }; + } + + if (item.category === 'receive' && item.confirmations! < 3) { + return { + label: loc.transactions.pending_transaction, + icon: , + }; + } + + // Recovered Arkade Lightning legs are bitcoind_tx but represent Boltz swaps, + // not on-chain transfers — render them with the off-chain (Lightning) icon. + if (arkRowKind === 'Lightning' && item.type === 'bitcoind_tx') { + return item.value! < 0 + ? { label: loc.transactions.offchain, icon: } + : { label: loc.transactions.incoming_transaction, icon: }; + } + + if (item.type && item.type === 'bitcoind_tx') { + return { + label: loc.transactions.onchain, + icon: , + }; + } + + if (item.type === 'paid_invoice') { + return { + label: loc.transactions.offchain, + icon: , + }; + } + + if (item.type === 'user_invoice' || item.type === 'payment_request') { + const currentDate = new Date(); + const now = (currentDate.getTime() / 1000) | 0; // eslint-disable-line no-bitwise + const invoiceExpiration = item.timestamp! + item.expire_time!; + if (!item.ispaid && invoiceExpiration < now) { + return { + label: loc.transactions.expired_transaction, + icon: , + }; + } else if (!item.ispaid) { + return { + label: loc.transactions.expired_transaction, + icon: , + }; + } else { + return { + label: loc.transactions.incoming_transaction, + icon: , + }; + } + } + + if (!item.confirmations) { + return { + label: loc.transactions.pending_transaction, + icon: , + }; + } else if (item.value! < 0) { + return { + label: loc.transactions.outgoing_transaction, + icon: , + }; + } else { + return { + label: loc.transactions.incoming_transaction, + icon: , + }; + } + }; + + const { label: transactionTypeLabel, icon: avatar } = determineTransactionTypeAndAvatar(); + + const amountWithUnit = useMemo(() => { + const unitSuffix = itemPriceUnit === BitcoinUnit.BTC || itemPriceUnit === BitcoinUnit.SATS ? ` ${itemPriceUnit}` : ' '; + return `${formattedAmount}${unitSuffix}`; + }, [formattedAmount, itemPriceUnit]); + + const onPress = useCallback(async () => { + // If a custom onPress handler was provided, use it and return + if (customOnPress) { + customOnPress(); + if (disableNavigation) return; + } + + if (item.hash) { + if (renderHighlightedText) { + pop(); + } + navigate('TransactionStatus', { hash: item.hash, walletID, tx: item }); + } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) { + // A settled Arkade swap is an enriched native Ark leg (type 'bitcoind_tx') + // carrying the swap's invoice payload (payment_request/hash/preimage). Route + // it to the Lightning invoice view by that payload, not by type — otherwise + // it falls through to the on-chain TransactionStatus branch below. + const lightningWallet = wallets.filter(wallet => wallet?.getID() === item.walletID); + if (lightningWallet.length === 1) { + try { + // is it a successful lnurl-pay? + const LN = new Lnurl(false, AsyncStorage); + const rawPaymentHash = item.payment_hash; + if (!rawPaymentHash) throw new Error('Missing payment hash'); + const normalizedPaymentHash = + typeof rawPaymentHash === 'string' ? rawPaymentHash : uint8ArrayToHex(new Uint8Array((rawPaymentHash as any).data)); + const loaded = await LN.loadSuccessfulPayment(normalizedPaymentHash); + if (loaded) { + navigate('ScanLNDInvoiceRoot', { + screen: 'LnurlPaySuccess', + params: { + paymentHash: normalizedPaymentHash, + justPaid: false, + fromWalletID: lightningWallet[0].getID(), + }, + }); + return; + } + } catch (e) { + console.debug(e); + } + + navigate('LNDViewInvoice', { + invoice: item, + walletID: lightningWallet[0].getID(), + }); + } + } else if ((item as { txid?: string }).txid) { + // Hash-less Ark rows carry a synthetic `txid`. Native transfer legs + // (`ark-…`) open the hash-less-tolerant TransactionStatus detail. Refill + // rows (`boarding-…` / `boarding-utxo-…`) have no detail surface and are + // not tappable — matching master, where on-chain top-ups aren't tappable. + const txid = (item as { txid: string }).txid; + if (!txid.startsWith('boarding-')) { + navigate('TransactionStatus', { tx: item, hash: txid, walletID }); + } + } + }, [item, renderHighlightedText, navigate, walletID, wallets, customOnPress, disableNavigation]); + + const handleOnDetailsPress = useCallback(() => { + if (walletID && item && item.hash) { + navigate('TransactionStatus', { hash: item.hash, walletID, tx: item }); + } else if (item.type === 'user_invoice' || item.type === 'payment_request' || item.type === 'paid_invoice' || item.payment_request) { + // Settled Arkade swaps carry invoice data on a 'bitcoind_tx' leg; route by + // payload so they open the Lightning invoice view (see onPress above). + const lightningWallet = wallets.find(wallet => wallet?.getID() === item.walletID); + if (lightningWallet) { + navigate('LNDViewInvoice', { + invoice: item, + walletID: lightningWallet.getID(), + }); + } + } else if ((item as { txid?: string }).txid) { + // Match the regular tap path for Ark non-swap rows: native transfer legs + // open TransactionStatus; refills (`boarding-…`) are not tappable (master). + const txid = (item as { txid: string }).txid; + if (!txid.startsWith('boarding-')) { + navigate('TransactionStatus', { tx: item, hash: txid, walletID }); + } + } + }, [item, navigate, walletID, wallets]); + + const handleOnCopyAmountTap = useCallback(() => Clipboard.setString(rowTitle.replace(/[\s\\-]/g, '')), [rowTitle]); + const handleOnCopyTransactionID = useCallback(() => Clipboard.setString(item.hash), [item.hash]); + const handleOnCopyNote = useCallback(() => Clipboard.setString(noteForCopy ?? ''), [noteForCopy]); + const handleOnViewOnBlockExplorer = useCallback(() => { + const url = `${selectedBlockExplorer.url}/tx/${item.hash}`; + Linking.canOpenURL(url).then(supported => { + if (supported) { + Linking.openURL(url); + } + }); + }, [item.hash, selectedBlockExplorer]); + const handleCopyOpenInBlockExplorerPress = useCallback(() => { + Clipboard.setString(`${selectedBlockExplorer.url}/tx/${item.hash}`); + }, [item.hash, selectedBlockExplorer]); + + const onToolTipPress = useCallback( + (id: any) => { + if (id === CommonToolTipActions.CopyAmount.id) { + handleOnCopyAmountTap(); + } else if (id === CommonToolTipActions.CopyNote.id) { + handleOnCopyNote(); + } else if (id === CommonToolTipActions.OpenInBlockExplorer.id) { + handleOnViewOnBlockExplorer(); + } else if (id === CommonToolTipActions.CopyBlockExplorerLink.id) { + handleCopyOpenInBlockExplorerPress(); + } else if (id === CommonToolTipActions.CopyTXID.id) { + handleOnCopyTransactionID(); + } else if (id === CommonToolTipActions.Details.id) { + handleOnDetailsPress(); + } + }, + [ + handleCopyOpenInBlockExplorerPress, + handleOnCopyAmountTap, + handleOnCopyNote, + handleOnCopyTransactionID, + handleOnDetailsPress, + handleOnViewOnBlockExplorer, + ], + ); + const toolTipActions = useMemo((): Action[] => { + const actions: (Action | Action[])[] = [ + { + ...CommonToolTipActions.CopyAmount, + hidden: rowTitle === loc.lnd.expired, + }, + { + ...CommonToolTipActions.CopyNote, + hidden: !noteForCopy, + }, + { + ...CommonToolTipActions.CopyTXID, + hidden: !item.hash, + }, + { + ...CommonToolTipActions.CopyBlockExplorerLink, + hidden: !item.hash, + }, + [{ ...CommonToolTipActions.OpenInBlockExplorer, hidden: !item.hash }, CommonToolTipActions.Details], + ]; + + return actions as Action[]; + }, [rowTitle, noteForCopy, item.hash]); + + const title = listTitle; + const subtitle = dateLine; + const subtitleNumberOfLines: number = 1; + + const titleStyle = useMemo(() => ({ color: colors.foregroundColor }), [colors.foregroundColor]); + const subtitleStyle = useMemo(() => ({ color: colors.alternativeTextColor }), [colors.alternativeTextColor]); + + const subtitleContent = useMemo(() => { + if (!subtitle) return null; + const maxLines = subtitleNumberOfLines === 0 ? undefined : subtitleNumberOfLines; + + if (renderHighlightedText && searchQuery) { + const highlighted = renderHighlightedText(subtitle, searchQuery); + if (React.isValidElement(highlighted)) { + const highlightedElement = highlighted as React.ReactElement<{ + numberOfLines?: number; + style?: TextStyle | TextStyle[]; + }>; + const existingStyle = highlightedElement.props?.style; + const mergedStyle: TextStyle[] = ( + Array.isArray(existingStyle) + ? [styles.subtitle, subtitleStyle, ...existingStyle] + : [styles.subtitle, subtitleStyle, existingStyle] + ).filter(Boolean) as TextStyle[]; + + return React.cloneElement(highlightedElement, { + numberOfLines: maxLines, + style: mergedStyle, + }); + } + return highlighted; + } + + return ( + + {subtitle} + + ); + }, [subtitle, subtitleNumberOfLines, renderHighlightedText, searchQuery, subtitleStyle]); + + return ( + + + {/* @ts-ignore - Context menu wrapper types can be overly strict about child element props */} + + + {avatar} + + + {title} + + {subtitleContent} + + + + {rowTitle} + + + + + + + ); +}; + +export const TransactionListItem = memo(TransactionListItemComponent); diff --git a/components/TransactionPendingIconBig.js b/components/TransactionPendingIconBig.js deleted file mode 100644 index 483051b6fe0..00000000000 --- a/components/TransactionPendingIconBig.js +++ /dev/null @@ -1,32 +0,0 @@ -/* eslint react/prop-types: "off", react-native/no-inline-styles: "off" */ -import { useTheme } from '@react-navigation/native'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import React from 'react'; - -export const TransactionPendingIconBig = props => { - const { colors } = useTheme(); - - const stylesBlueIconHooks = StyleSheet.create({ - ball: { - backgroundColor: colors.buttonBackgroundColor, - }, - ball2: { - width: 150, - height: 150, - borderRadius: 75, - }, - boxIncoming: { - position: 'relative', - }, - }); - return ( - - - - - - - - ); -}; diff --git a/components/TransactionPendingIconBig.tsx b/components/TransactionPendingIconBig.tsx new file mode 100644 index 00000000000..fcb43d60f9f --- /dev/null +++ b/components/TransactionPendingIconBig.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Icon from './Icon'; + +import { useTheme } from './themes'; + +export const TransactionPendingIconBig: React.FC = () => { + const { colors } = useTheme(); + + const hookStyles = StyleSheet.create({ + ball: { + backgroundColor: colors.buttonBackgroundColor, + }, + }); + + return ( + + + + + + + + ); +}; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + }, + ball: { + width: 150, + height: 150, + borderRadius: 75, + }, + iconStyle: { + left: 0, + top: 25, + }, +}); diff --git a/components/TransactionStateHeader.tsx b/components/TransactionStateHeader.tsx new file mode 100644 index 00000000000..3fe944b8111 --- /dev/null +++ b/components/TransactionStateHeader.tsx @@ -0,0 +1,136 @@ +import React, { useEffect } from 'react'; +import { StyleProp, StyleSheet, TextStyle, TouchableOpacity, View } from 'react-native'; +import Animated, { Easing, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; + +import BlueText from './BlueText'; +import Icon from './Icon'; +import TransactionIncomingIcon from './icons/TransactionIncomingIcon'; +import TransactionOutgoingIcon from './icons/TransactionOutgoingIcon'; +import loc from '../loc'; + +type TransactionDirection = 'sent' | 'received'; + +const ICON_BOX_SIZE = 30; +const EXPAND_ICON_SIZE = 20; +const EXPAND_ANIMATION_DURATION = 280; + +interface TransactionStateHeaderProps { + direction: TransactionDirection; + confirmations: number; + isOnChainTx: boolean; + isExpanded?: boolean; + onPress?: () => void; + labelStyle: StyleProp; + valueStyle: StyleProp; + accentColor: string; +} + +interface ExpandChevronProps { + isExpanded: boolean; + color: string; +} + +const ExpandChevron: React.FC = ({ isExpanded, color }) => { + const expandProgress = useSharedValue(isExpanded ? 1 : 0); + + useEffect(() => { + expandProgress.value = withTiming(isExpanded ? 1 : 0, { + duration: EXPAND_ANIMATION_DURATION, + easing: Easing.out(Easing.cubic), + }); + }, [expandProgress, isExpanded]); + + const expandIconStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${expandProgress.value * 180}deg` }], + })); + + return ( + + + + ); +}; + +const TransactionStateHeader: React.FC = ({ + direction, + confirmations, + isOnChainTx, + isExpanded = false, + onPress, + labelStyle, + valueStyle, + accentColor, +}) => { + const DirectionIcon = direction === 'sent' ? TransactionOutgoingIcon : TransactionIncomingIcon; + const label = direction === 'sent' ? loc.transactions.details_sent : loc.transactions.details_received; + const displayConfirmations = !Number.isFinite(confirmations) || confirmations <= 0 ? null : confirmations > 6 ? '6+' : confirmations; + + const content = ( + <> + + + + + {label} + {isOnChainTx && displayConfirmations !== null && ( + + {loc.formatString(loc.transactions.confirmations_lowercase, { + confirmations: displayConfirmations, + })} + + )} + + {onPress && } + + ); + + if (onPress) { + return ( + + {content} + + ); + } + + return {content}; +}; + +const styles = StyleSheet.create({ + stateHeaderRow: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + }, + iconBox: { + width: ICON_BOX_SIZE, + height: ICON_BOX_SIZE, + justifyContent: 'center', + alignItems: 'center', + }, + stateLabelContainer: { + flexDirection: 'column', + alignItems: 'flex-start', + marginHorizontal: 8, + flex: 1, + minWidth: 0, + }, + stateLabel: { + fontSize: 16, + fontWeight: '600', + lineHeight: 22, + marginBottom: 0, + }, + stateValue: { + fontSize: 13, + marginBottom: 0, + marginTop: 0, + }, +}); + +export default TransactionStateHeader; diff --git a/components/TransactionsNavigationHeader.tsx b/components/TransactionsNavigationHeader.tsx index 76bdc8e26a9..61fa37513af 100644 --- a/components/TransactionsNavigationHeader.tsx +++ b/components/TransactionsNavigationHeader.tsx @@ -1,97 +1,88 @@ -import React, { useState, useEffect, useRef, useContext, useCallback, useMemo } from 'react'; -import { Image, Text, TouchableOpacity, View, I18nManager, StyleSheet } from 'react-native'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import Clipboard from '@react-native-clipboard/clipboard'; +import { Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import LinearGradient from 'react-native-linear-gradient'; -import { AbstractWallet, HDSegwitBech32Wallet, LightningCustodianWallet, LightningLdkWallet, MultisigHDWallet } from '../class'; -import { BitcoinUnit } from '../models/bitcoinUnits'; +import { useTheme } from './themes'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet'; +import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet'; import WalletGradient from '../class/wallet-gradient'; -import Biometric from '../class/biometrics'; -import loc, { formatBalance } from '../loc'; -import { BlueStorageContext } from '../blue_modules/storage-context'; +import { TWallet } from '../class/wallets/types'; +import loc, { formatBalance, formatBalanceWithoutSuffix } from '../loc'; +import { BitcoinUnit } from '../models/bitcoinUnits'; +import { FiatUnit } from '../models/fiatUnit'; +import { BlurredBalanceView } from './BlurredBalanceView'; +import { useSettings } from '../hooks/context/useSettings'; import ToolTipMenu from './TooltipMenu'; -import { BluePrivateBalance } from '../BlueComponents'; +import { useLocale } from '@react-navigation/native'; +import ActionSheet from '../screen/ActionSheet'; + +const HERO_BASE_BODY_MIN_HEIGHT = 120; +const HERO_MIN_BODY_HEIGHT = Math.round(HERO_BASE_BODY_MIN_HEIGHT * 1.2); +const HERO_BOTTOM_PADDING = 32; +const WALLET_LABEL_TOP_GAP = 32; interface TransactionsNavigationHeaderProps { - wallet: AbstractWallet; - onWalletUnitChange?: (wallet: any) => void; - navigation: { - navigate: (route: string, params?: any) => void; - goBack: () => void; - }; - onManageFundsPressed?: (id: string) => void; // Add a type definition for this prop - actionKeys: { - CopyToClipboard: 'copyToClipboard'; - WalletBalanceVisibility: 'walletBalanceVisibility'; - Refill: 'refill'; - RefillWithExternalWallet: 'qrcode'; - }; + wallet: TWallet; + unit: BitcoinUnit; + headerOverlayHeight: number; + onWalletUnitChange: (unit: BitcoinUnit) => void; + onManageFundsPressed?: (id?: string) => void; + onWalletBalanceVisibilityChange?: (shouldHideBalance: boolean) => void; + unitSwitching?: boolean; } const TransactionsNavigationHeader: React.FC = ({ - // @ts-ignore: Ugh - wallet: initialWallet, - // @ts-ignore: Ugh + wallet, + headerOverlayHeight, onWalletUnitChange, - // @ts-ignore: Ugh - navigation, - // @ts-ignore: Ugh onManageFundsPressed, + onWalletBalanceVisibilityChange, + unit = BitcoinUnit.BTC, + unitSwitching = false, }) => { - const [wallet, setWallet] = useState(initialWallet); - const [allowOnchainAddress, setAllowOnchainAddress] = useState(false); - - const context = useContext(BlueStorageContext); - const menuRef = useRef(null); + const { colors } = useTheme(); + const { hideBalance } = wallet; + const isLightningWallet = wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type; + const [allowOnchainAddress, setAllowOnchainAddress] = useState(isLightningWallet); + const { preferredFiatCurrency } = useSettings(); + const { direction } = useLocale(); const verifyIfWalletAllowsOnchainAddress = useCallback(() => { - if (wallet.type === LightningCustodianWallet.type) { + if (isLightningWallet) { wallet .allowOnchainAddress() .then((value: boolean) => setAllowOnchainAddress(value)) - .catch((e: any) => { - console.log('This Lndhub wallet does not have an onchain address API.'); + .catch(() => { + console.error('This LNDhub wallet does not have an onchain address API.'); setAllowOnchainAddress(false); }); } - }, [wallet]); + }, [isLightningWallet, wallet]); + + useEffect(() => { + setAllowOnchainAddress(isLightningWallet); + }, [isLightningWallet]); useEffect(() => { verifyIfWalletAllowsOnchainAddress(); }, [wallet, verifyIfWalletAllowsOnchainAddress]); - const handleCopyPress = () => { - Clipboard.setString(formatBalance(wallet.getBalance(), wallet.getPreferredBalanceUnit()).toString()); - }; - - const updateWalletVisibility = (w: AbstractWallet, newHideBalance: boolean) => { - w.hideBalance = newHideBalance; - return w; - }; - - const handleBalanceVisibility = async () => { - // @ts-ignore: Gotta update this class - const isBiometricsEnabled = await Biometric.isBiometricUseCapableAndEnabled(); - - if (isBiometricsEnabled && wallet.hideBalance) { - // @ts-ignore: Ugh - if (!(await Biometric.unlockWithBiometrics())) { - return navigation.goBack(); - } + const handleCopyPress = useCallback(() => { + const value = formatBalance(wallet.getBalance(), unit); + if (value) { + Clipboard.setString(value); } + }, [unit, wallet]); - const updatedWallet = updateWalletVisibility(wallet, !wallet.hideBalance); - setWallet(updatedWallet); - context.saveToDisk(); - }; - - const updateWalletWithNewUnit = (w: AbstractWallet, newPreferredUnit: BitcoinUnit) => { - w.preferredBalanceUnit = newPreferredUnit; - return w; - }; + const handleBalanceVisibility = useCallback(() => { + onWalletBalanceVisibilityChange?.(!hideBalance); + }, [hideBalance, onWalletBalanceVisibilityChange]); const changeWalletBalanceUnit = () => { - // @ts-ignore: Ugh - menuRef.current?.dismissMenu(); + if (hideBalance) { + return; + } let newWalletPreferredUnit = wallet.getPreferredBalanceUnit(); if (newWalletPreferredUnit === BitcoinUnit.BTC) { @@ -102,188 +93,215 @@ const TransactionsNavigationHeader: React.FC newWalletPreferredUnit = BitcoinUnit.BTC; } - const updatedWallet = updateWalletWithNewUnit(wallet, newWalletPreferredUnit); - setWallet(updatedWallet); - onWalletUnitChange?.(updatedWallet); + onWalletUnitChange(newWalletPreferredUnit); }; - const handleManageFundsPressed = () => { - onManageFundsPressed?.(actionKeys.Refill); - }; + const handleManageFundsPressed = useCallback( + (actionKeyID?: string) => { + if (onManageFundsPressed) { + onManageFundsPressed(actionKeyID); + } + }, + [onManageFundsPressed], + ); - const handleOnPaymentCodeButtonPressed = () => { - navigation.navigate('PaymentCodeRoot', { - screen: 'PaymentCode', - params: { paymentCode: (wallet as HDSegwitBech32Wallet).getBIP47PaymentCode() }, - }); - }; + const onPressMenuItem = useCallback( + (id: string) => { + if (id === actionKeys.WalletBalanceVisibility) { + handleBalanceVisibility(); + } else if (id === actionKeys.CopyToClipboard) { + handleCopyPress(); + } + }, + [handleBalanceVisibility, handleCopyPress], + ); - const onPressMenuItem = (id: string) => { - if (id === 'walletBalanceVisibility') { - handleBalanceVisibility(); - } else if (id === 'copyToClipboard') { - handleCopyPress(); - } - }; + // The Manage Funds menu is presented via a JS ActionSheet rather than the + // native context menu (ToolTipMenu): react-native-context-menu-view is + // Paper-only and, routed through Fabric's legacy interop on the New + // Architecture, its host view gets mispositioned to the header origin — + // overlapping the wallet label. A plain TouchableOpacity + ActionSheet lays + // out correctly (same pattern as the Multisig button below). + const showManageFundsActionSheet = useCallback(() => { + ActionSheet.showActionSheetWithOptions( + { + title: loc.lnd.title, + options: [loc._.cancel, loc.lnd.refill, loc.lnd.refill_external], + cancelButtonIndex: 0, + }, + buttonIndex => { + if (buttonIndex === 1) handleManageFundsPressed(actionKeys.Refill); + else if (buttonIndex === 2) handleManageFundsPressed(actionKeys.RefillWithExternalWallet); + }, + ); + }, [handleManageFundsPressed]); + + const currentBalance = wallet ? wallet.getBalance() : 0; + const formattedBalance = useMemo(() => { + return unit === BitcoinUnit.LOCAL_CURRENCY + ? formatBalance(currentBalance, unit, true) + : formatBalanceWithoutSuffix(currentBalance, unit, true); + }, [unit, currentBalance]); - const balance = useMemo(() => { - const hideBalance = wallet.hideBalance; - const balanceUnit = wallet.getPreferredBalanceUnit(); - const balanceFormatted = formatBalance(wallet.getBalance(), balanceUnit, true).toString(); - return !hideBalance && balanceFormatted; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [wallet.hideBalance, wallet.getPreferredBalanceUnit()]); + const balance = !wallet.hideBalance && formattedBalance; + const toolTipWalletBalanceActions = useMemo(() => { + return hideBalance + ? [ + { + id: actionKeys.WalletBalanceVisibility, + text: loc.transactions.details_balance_show, + icon: actionIcons.Eye, + }, + ] + : [ + { + id: actionKeys.WalletBalanceVisibility, + text: loc.transactions.details_balance_hide, + icon: actionIcons.EyeSlash, + }, + { + id: actionKeys.CopyToClipboard, + text: loc.transactions.details_copy, + icon: actionIcons.Clipboard, + }, + ]; + }, [hideBalance]); + + // Hero extends under the transparent nav bar (paddingTop: headerOverlayHeight). + // Without box-none / none, that overlay and absoluteFill gradient steal taps from + // JS headerRight (Wallet Details "…") on iOS < 26 and Mac Catalyst. return ( - - { - switch (wallet.type) { - case LightningLdkWallet.type: - case LightningCustodianWallet.type: - return I18nManager.isRTL ? require('../img/lnd-shape-rtl.png') : require('../img/lnd-shape.png'); - case MultisigHDWallet.type: - return I18nManager.isRTL ? require('../img/vault-shape-rtl.png') : require('../img/vault-shape.png'); - default: - return I18nManager.isRTL ? require('../img/btc-shape-rtl.png') : require('../img/btc-shape.png'); - } - })()} - style={styles.chainIcon} - /> - - {wallet.getLabel()} - - - - {wallet.hideBalance ? ( - - ) : ( - + + + {wallet.getLabel()} + + + + - {balance} - + + {hideBalance ? ( + + ) : ( + + {balance} + + )} + + + {!hideBalance && ( + + + {unit === BitcoinUnit.LOCAL_CURRENCY ? (preferredFiatCurrency?.endPointKey ?? FiatUnit.USD) : unit} + + + )} + + {(wallet.type === LightningCustodianWallet.type || wallet.type === LightningArkWallet.type) && allowOnchainAddress && ( + + {loc.lnd.title} + )} - - {wallet.type === LightningCustodianWallet.type && allowOnchainAddress && ( - handleManageFundsPressed()}> + {loc.multisig.manage_keys} + + )} + + + - {loc.lnd.title} - - )} - - {wallet.allowBIP47() && wallet.isBIP47Enabled() && ( - - - {loc.bip47.payment_code} - - - )} - {wallet.type === LightningLdkWallet.type && ( - - - {loc.lnd.title} - - - )} - {wallet.type === MultisigHDWallet.type && ( - - - {loc.multisig.manage_keys} - - - )} - + /> + + ); }; const styles = StyleSheet.create({ lineaderGradient: { - padding: 15, - minHeight: 140, - justifyContent: 'center', + justifyContent: 'flex-start', + position: 'relative', }, - chainIcon: { - width: 99, - height: 94, + contentContainer: { + flex: 1, + paddingTop: WALLET_LABEL_TOP_GAP, + paddingHorizontal: 16, + paddingBottom: HERO_BOTTOM_PADDING, + }, + bottomBarSpacer: { + position: 'relative', + height: 12, + marginBottom: 0, + }, + bottomBar: { position: 'absolute', - bottom: 0, + left: 0, right: 0, + bottom: -1, + height: 13, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + ...Platform.select({ + ios: { + shadowOffset: { width: 0, height: -8 }, + shadowOpacity: 0.1, + shadowRadius: 6, + }, + android: { + elevation: 0.5, + }, + }), }, walletLabel: { backgroundColor: 'transparent', fontSize: 19, - color: '#fff', - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + color: 'rgba(255, 255, 255, 0.7)', + marginBottom: 4, }, walletBalance: { - backgroundColor: 'transparent', - fontWeight: 'bold', - fontSize: 36, - color: '#fff', - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', + flexShrink: 1, + marginRight: 6, + minHeight: 39, + justifyContent: 'center', + }, + balanceSection: { + flexDirection: 'column', + alignItems: 'flex-start', }, manageFundsButton: { marginTop: 14, @@ -301,34 +319,52 @@ const styles = StyleSheet.create({ color: '#FFFFFF', padding: 12, }, + walletBalanceAndUnitContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingRight: 10, + }, + walletBalanceText: { + color: '#fff', + fontWeight: 'bold', + fontSize: 36, + flexShrink: 1, + }, + walletPreferredUnitView: { + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(255, 255, 255, 0.25)', + borderRadius: 8, + minHeight: 35, + minWidth: 65, + }, + walletPreferredUnitText: { + color: '#fff', + fontWeight: '600', + }, }); export const actionKeys = { CopyToClipboard: 'copyToClipboard', WalletBalanceVisibility: 'walletBalanceVisibility', Refill: 'refill', - RefillWithExternalWallet: 'qrcode', + RefillWithExternalWallet: 'refillWithExternalWallet', }; export const actionIcons = { Eye: { - iconType: 'SYSTEM', iconValue: 'eye', }, EyeSlash: { - iconType: 'SYSTEM', iconValue: 'eye.slash', }, Clipboard: { - iconType: 'SYSTEM', iconValue: 'doc.on.doc', }, Refill: { - iconType: 'SYSTEM', iconValue: 'goforward.plus', }, RefillWithExternalWallet: { - iconType: 'SYSTEM', iconValue: 'qrcode', }, }; diff --git a/components/WalletButton.tsx b/components/WalletButton.tsx new file mode 100644 index 00000000000..fa4917eb883 --- /dev/null +++ b/components/WalletButton.tsx @@ -0,0 +1,128 @@ +import React from 'react'; +import { ColorValue, DimensionValue, Image, ImageSourcePropType, StyleSheet, Text, Pressable, View } from 'react-native'; +import { useLocale } from '@react-navigation/native'; + +import loc from '../loc'; +import { Theme, useTheme } from './themes'; + +interface ButtonDetails { + image: ImageSourcePropType; + title: string; + explain: string; + borderColorActive: keyof Theme['colors']; +} + +interface WalletButtonProps { + buttonType: keyof typeof buttonDetails; + testID?: string; + onPress: () => void; + size: { + width: DimensionValue | undefined; + height: DimensionValue | undefined; + }; + active: boolean; +} + +const buttonDetails: Record = { + Bitcoin: { + image: require('../img/addWallet/bitcoin.png'), + title: loc.wallets.add_bitcoin, + explain: loc.wallets.add_bitcoin_explain, + borderColorActive: 'newBlue', + }, + Vault: { + image: require('../img/addWallet/vault.png'), + title: loc.multisig.multisig_vault, + explain: loc.multisig.multisig_vault_explain, + borderColorActive: 'foregroundColor', + }, + Lightning: { + image: require('../img/addWallet/lightning.png'), + title: loc.wallets.add_lightning, + explain: loc.wallets.add_lightning_explain, + borderColorActive: 'lnborderColor', + }, + LightningArk: { + image: require('../img/addWallet/lightning.png'), + title: loc.wallets.add_lightning, + explain: loc.wallets.add_lightning_explain + '\nPowered by Arkade', + borderColorActive: 'lnborderColor', + }, +}; + +const WalletButton: React.FC = ({ buttonType, testID, onPress, size, active }) => { + const details = buttonDetails[buttonType]; + const { colors } = useTheme(); + const { direction } = useLocale(); + const borderColor = active ? colors[details.borderColorActive] : colors.buttonDisabledBackgroundColor; + const stylesHook = StyleSheet.create({ + buttonContainer: { + borderColor: borderColor as ColorValue, + backgroundColor: colors.buttonDisabledBackgroundColor, + minWidth: size.width, + minHeight: size.height, + height: size.height, + }, + textTitle: { + color: colors[details.borderColorActive] as ColorValue, + fontWeight: 'bold', + fontSize: 18, + writingDirection: direction, + }, + textExplain: { + color: colors.alternativeTextColor, + fontSize: 13, + fontWeight: '500', + writingDirection: direction, + }, + }); + + return ( + [pressed && styles.pressed, styles.touchable]} + > + + + + + {details.title} + {details.explain} + + + + + ); +}; + +const styles = StyleSheet.create({ + touchable: { + flex: 1, + marginBottom: 8, + }, + container: { + borderWidth: 1.5, + borderRadius: 8, + }, + content: { + marginHorizontal: 16, + marginVertical: 10, + flexDirection: 'row', + alignItems: 'center', + }, + image: { + width: 34, + height: 34, + marginRight: 8, + }, + textContainer: { + flex: 1, + }, + pressed: { + opacity: 0.6, + }, +}); + +export default WalletButton; diff --git a/components/WalletListItem.tsx b/components/WalletListItem.tsx new file mode 100644 index 00000000000..03bfe602403 --- /dev/null +++ b/components/WalletListItem.tsx @@ -0,0 +1,180 @@ +import React, { useMemo } from 'react'; +import { ImageBackground, ImageSourcePropType, StyleSheet, Text, View, ViewStyle, TextStyle, Pressable } from 'react-native'; +import LinearGradient from 'react-native-linear-gradient'; +import { useLocale } from '@react-navigation/native'; +import { useTheme } from './themes'; +import HighlightedText from './HighlightedText'; +import { TWallet } from '../class/wallets/types'; +import WalletGradient from '../class/wallet-gradient'; +import { formatBalance } from '../loc'; + +type Props = { + wallet: TWallet; + iconImage: ImageSourcePropType; + onPress: () => void; + searchQuery: string; + borderBottomColor: string; + backgroundColor: string; + titleColor: string; + onLongPress?: () => void; + delayLongPress?: number; + onPressIn?: () => void; + onPressOut?: () => void; + isActive?: boolean; + containerStyle?: ViewStyle; + balanceColor?: string; +}; + +const WalletListItem: React.FC = ({ + wallet, + iconImage, + onPress, + onLongPress, + delayLongPress = 120, + onPressIn, + onPressOut, + searchQuery, + isActive, + containerStyle, + borderBottomColor, + backgroundColor, + titleColor, + balanceColor, +}) => { + const { colors, dark } = useTheme(); + const { direction } = useLocale(); + + const walletLabel = wallet.getLabel(); + const gradientColors = WalletGradient.gradientsFor(wallet.type); + + const resolvedTitleColor = titleColor ?? (dark ? colors.foregroundColor : colors.darkGray); + const resolvedBalanceColor = balanceColor ?? colors.alternativeTextColor; + const resolvedBorderBottomColor = borderBottomColor ?? colors.lightBorder; + const resolvedBackgroundColor = backgroundColor ?? colors.background; + + const titleTextStyle = useMemo( + () => [styles.listItemLabel, { color: resolvedTitleColor, writingDirection: direction }] as TextStyle[], + [direction, resolvedTitleColor], + ); + const balanceTextStyle = useMemo( + () => [styles.listItemBalance, { color: resolvedBalanceColor, writingDirection: direction }] as TextStyle[], + [direction, resolvedBalanceColor], + ); + + const balance = useMemo(() => { + if (wallet.hideBalance) return ''; + return formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true); + }, [wallet]); + + const highlightStyle = useMemo(() => { + if (dark) { + return StyleSheet.flatten([styles.highlightDark, { color: resolvedTitleColor }]); + } + + // On light backgrounds, HighlightedText's default white-ish border can be hard to see. + return StyleSheet.flatten([styles.highlightLight, { color: resolvedTitleColor }]); + }, [dark, resolvedTitleColor]); + + return ( + [ + styles.listItem, + { + backgroundColor: isActive ? colors.lightButton : resolvedBackgroundColor, + borderBottomColor: resolvedBorderBottomColor, + }, + pressed && styles.pressed, + containerStyle, + ]} + onPress={onPress} + onLongPress={onLongPress} + delayLongPress={onLongPress ? delayLongPress : undefined} + onPressIn={onPressIn} + onPressOut={onPressOut} + accessibilityRole="button" + testID={walletLabel} + > + + + + + {searchQuery ? ( + + ) : ( + + {walletLabel} + + )} + + {wallet.hideBalance ? ( + + + + ) : ( + + {balance} + + )} + + + ); +}; + +const styles = StyleSheet.create({ + listItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 16, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + iconBox: { + width: 46, + height: 46, + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + overflow: 'hidden', + }, + iconImage: { + width: 46, + height: 46, + resizeMode: 'center', + }, + listItemContent: { + flex: 1, + marginLeft: 14, + justifyContent: 'center', + }, + listItemLabel: { + fontSize: 17, + fontWeight: '600', + marginBottom: 2, + }, + listItemBalance: { + fontSize: 14, + }, + pressed: { + opacity: 0.85, + }, + hiddenBalance: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + hiddenBalanceBar: { + backgroundColor: 'rgba(150, 150, 150, 0.25)', + height: 10, + width: 66, + borderRadius: 5, + }, + highlightDark: { + backgroundColor: 'rgba(255, 245, 192, 0.22)', + borderColor: 'rgba(255, 255, 255, 0.25)', + }, + highlightLight: { + borderColor: 'rgba(0, 0, 0, 0.12)', + }, +}); + +export default WalletListItem; diff --git a/components/WalletToImport.js b/components/WalletToImport.js deleted file mode 100644 index bfd14dfc538..00000000000 --- a/components/WalletToImport.js +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Text } from 'react-native-elements'; -import { I18nManager, StyleSheet, TouchableOpacity, View } from 'react-native'; -import { useTheme } from '@react-navigation/native'; - -const WalletToImport = ({ title, subtitle, active, onPress }) => { - const { colors } = useTheme(); - - const stylesHooks = StyleSheet.create({ - root: { - borderColor: active ? colors.newBlue : colors.buttonDisabledBackgroundColor, - backgroundColor: colors.buttonDisabledBackgroundColor, - }, - title: { - color: colors.newBlue, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - subtitle: { - color: colors.alternativeTextColor, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - }); - - return ( - - - {title} - {subtitle} - - - ); -}; - -const styles = StyleSheet.create({ - root: { - alignItems: 'stretch', - borderRadius: 8, - borderWidth: 1.5, - flexDirection: 'column', - justifyContent: 'center', - marginBottom: 8, - minWidth: '100%', - paddingHorizontal: 16, - paddingVertical: 10, - }, - title: { - fontWeight: 'bold', - fontSize: 15, - paddingBottom: 3, - }, - subtitle: { - fontSize: 13, - fontWeight: '500', - }, -}); - -WalletToImport.propTypes = { - title: PropTypes.string, - subtitle: PropTypes.string, - active: PropTypes.bool, - onPress: PropTypes.func, -}; - -export default WalletToImport; diff --git a/components/WalletToImport.tsx b/components/WalletToImport.tsx new file mode 100644 index 00000000000..becce011711 --- /dev/null +++ b/components/WalletToImport.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { useLocale } from '@react-navigation/native'; + +import { useTheme } from './themes'; + +interface WalletToImportProp { + title: string; + subtitle: string; + active: boolean; + onPress: () => void; +} + +const WalletToImport: React.FC = ({ title, subtitle, active, onPress }) => { + const { colors } = useTheme(); + const { direction } = useLocale(); + + const stylesHooks = StyleSheet.create({ + root: { + borderColor: active ? colors.newBlue : colors.buttonDisabledBackgroundColor, + backgroundColor: colors.buttonDisabledBackgroundColor, + }, + title: { + color: colors.newBlue, + writingDirection: direction, + }, + subtitle: { + color: colors.alternativeTextColor, + writingDirection: direction, + }, + }); + + return ( + + + {title} + {subtitle} + + + ); +}; + +const styles = StyleSheet.create({ + root: { + alignItems: 'stretch', + borderRadius: 8, + borderWidth: 1.5, + flexDirection: 'column', + justifyContent: 'center', + marginBottom: 8, + minWidth: '100%', + paddingHorizontal: 16, + paddingVertical: 10, + }, + title: { + fontWeight: 'bold', + fontSize: 15, + paddingBottom: 3, + }, + subtitle: { + fontSize: 13, + fontWeight: '500', + }, +}); + +export default WalletToImport; diff --git a/components/WalletsCarousel.js b/components/WalletsCarousel.js deleted file mode 100644 index 65afac571f7..00000000000 --- a/components/WalletsCarousel.js +++ /dev/null @@ -1,357 +0,0 @@ -import React, { useRef, useCallback, useImperativeHandle, forwardRef, useContext } from 'react'; -import PropTypes from 'prop-types'; -import { - Animated, - Image, - I18nManager, - Platform, - StyleSheet, - Text, - TouchableOpacity, - useWindowDimensions, - View, - Dimensions, - FlatList, - Pressable, -} from 'react-native'; -import { useTheme } from '@react-navigation/native'; -import LinearGradient from 'react-native-linear-gradient'; -import loc, { formatBalance, transactionTimeToReadable } from '../loc'; -import { LightningCustodianWallet, LightningLdkWallet, MultisigHDWallet } from '../class'; -import WalletGradient from '../class/wallet-gradient'; -import { BluePrivateBalance } from '../BlueComponents'; -import { BlueStorageContext } from '../blue_modules/storage-context'; -import { isHandset, isTablet, isDesktop } from '../blue_modules/environment'; - -const nStyles = StyleSheet.create({ - container: { - borderRadius: 10, - minHeight: Platform.OS === 'ios' ? 164 : 181, - justifyContent: 'center', - alignItems: 'flex-start', - }, - addAWAllet: { - fontWeight: '600', - fontSize: 24, - marginBottom: 4, - }, - addLine: { - fontSize: 13, - }, - button: { - marginTop: 12, - backgroundColor: '#007AFF', - paddingHorizontal: 32, - paddingVertical: 12, - borderRadius: 8, - }, - buttonText: { - fontWeight: '500', - }, -}); - -const NewWalletPanel = ({ onPress }) => { - const { colors } = useTheme(); - const { width } = useWindowDimensions(); - const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82; - const isLargeScreen = Platform.OS === 'android' ? isTablet() : (width >= Dimensions.get('screen').width / 2 && isTablet()) || isDesktop; - const nStylesHooks = StyleSheet.create({ - container: isLargeScreen - ? { - paddingHorizontal: 24, - marginVertical: 16, - } - : { paddingVertical: 16, paddingHorizontal: 24 }, - }); - - return ( - - - {loc.wallets.list_create_a_wallet} - {loc.wallets.list_create_a_wallet_text} - - {loc.wallets.list_create_a_button} - - - - ); -}; - -NewWalletPanel.propTypes = { - onPress: PropTypes.func.isRequired, -}; - -const iStyles = StyleSheet.create({ - root: { paddingRight: 20 }, - rootLargeDevice: { marginVertical: 20 }, - grad: { - padding: 15, - borderRadius: 12, - minHeight: 164, - elevation: 5, - }, - image: { - width: 99, - height: 94, - position: 'absolute', - bottom: 0, - right: 0, - }, - br: { - backgroundColor: 'transparent', - }, - label: { - backgroundColor: 'transparent', - fontSize: 19, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - balance: { - backgroundColor: 'transparent', - fontWeight: 'bold', - fontSize: 36, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - latestTx: { - backgroundColor: 'transparent', - fontSize: 13, - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - }, - latestTxTime: { - backgroundColor: 'transparent', - fontWeight: 'bold', - writingDirection: I18nManager.isRTL ? 'rtl' : 'ltr', - fontSize: 16, - }, -}); - -const WalletCarouselItem = ({ item, index, onPress, handleLongPress, isSelectedWallet }) => { - const scaleValue = new Animated.Value(1.0); - const { colors } = useTheme(); - const { walletTransactionUpdateStatus } = useContext(BlueStorageContext); - const { width } = useWindowDimensions(); - const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82; - const isLargeScreen = Platform.OS === 'android' ? isTablet() : (width >= Dimensions.get('screen').width / 2 && isTablet()) || isDesktop; - const onPressedIn = () => { - const props = { duration: 50 }; - props.useNativeDriver = true; - props.toValue = 0.9; - Animated.spring(scaleValue, props).start(); - }; - - const onPressedOut = () => { - const props = { duration: 50 }; - props.useNativeDriver = true; - props.toValue = 1.0; - Animated.spring(scaleValue, props).start(); - }; - - const opacity = isSelectedWallet === false ? 0.5 : 1.0; - let image; - switch (item.type) { - case LightningLdkWallet.type: - case LightningCustodianWallet.type: - image = I18nManager.isRTL ? require('../img/lnd-shape-rtl.png') : require('../img/lnd-shape.png'); - break; - case MultisigHDWallet.type: - image = I18nManager.isRTL ? require('../img/vault-shape-rtl.png') : require('../img/vault-shape.png'); - break; - default: - image = I18nManager.isRTL ? require('../img/btc-shape-rtl.png') : require('../img/btc-shape.png'); - } - - const latestTransactionText = - walletTransactionUpdateStatus === true || walletTransactionUpdateStatus === item.getID() - ? loc.transactions.updating - : item.getBalance() !== 0 && item.getLatestTransactionTime() === 0 - ? loc.wallets.pull_to_refresh - : item.getTransactions().find(tx => tx.confirmations === 0) - ? loc.transactions.pending - : transactionTimeToReadable(item.getLatestTransactionTime()); - - const balance = !item.hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true); - - return ( - - { - onPressedOut(); - onPress(item); - onPressedOut(); - }} - > - - - - - {item.getLabel()} - - {item.hideBalance ? ( - - ) : ( - - {balance} - - )} - - - {loc.wallets.list_latest_transaction} - - - - {latestTransactionText} - - - - - ); -}; - -WalletCarouselItem.propTypes = { - item: PropTypes.any, - index: PropTypes.number.isRequired, - onPress: PropTypes.func.isRequired, - handleLongPress: PropTypes.func.isRequired, - isSelectedWallet: PropTypes.bool, -}; - -const cStyles = StyleSheet.create({ - content: { - paddingTop: 16, - }, - contentLargeScreen: { - paddingHorizontal: 16, - }, - separatorStyle: { - width: 16, - height: 20, - }, -}); - -const ListHeaderComponent = () => ; - -const WalletsCarousel = forwardRef((props, ref) => { - const { preferredFiatCurrency, language } = useContext(BlueStorageContext); - const renderItem = useCallback( - ({ item, index }) => - item ? ( - - ) : ( - - ), - // eslint-disable-next-line react-hooks/exhaustive-deps - [props.horizontal, props.selectedWallet, props.handleLongPress, props.onPress, preferredFiatCurrency, language], - ); - const flatListRef = useRef(); - - useImperativeHandle(ref, () => ({ - scrollToItem: ({ item }) => { - setTimeout(() => { - flatListRef?.current?.scrollToItem({ item, viewOffset: 16 }); - }, 300); - }, - scrollToIndex: ({ index }) => { - setTimeout(() => { - flatListRef?.current?.scrollToIndex({ index, viewOffset: 16 }); - }, 300); - }, - })); - - const onScrollToIndexFailed = error => { - console.log('onScrollToIndexFailed'); - console.log(error); - flatListRef.current.scrollToOffset({ offset: error.averageItemLength * error.index, animated: true }); - setTimeout(() => { - if (props.data.length !== 0 && flatListRef.current !== null) { - flatListRef.current.scrollToIndex({ index: error.index, animated: true }); - } - }, 100); - }; - - const { width } = useWindowDimensions(); - const sliderHeight = 195; - const itemWidth = width * 0.82 > 375 ? 375 : width * 0.82; - return isHandset ? ( - index.toString()} - showsVerticalScrollIndicator={false} - pagingEnabled - disableIntervalMomentum={isHandset} - snapToInterval={itemWidth} // Adjust to your content width - decelerationRate="fast" - contentContainerStyle={props.horizontal ? cStyles.content : cStyles.contentLargeScreen} - directionalLockEnabled - showsHorizontalScrollIndicator={false} - initialNumToRender={10} - ListHeaderComponent={ListHeaderComponent} - style={props.horizontal ? { minHeight: sliderHeight + 9 } : {}} - onScrollToIndexFailed={onScrollToIndexFailed} - {...props} - /> - ) : ( - - {props.data.map((item, index) => - item ? ( - - ) : ( - - ), - )} - - ); -}); - -WalletsCarousel.propTypes = { - horizontal: PropTypes.bool, - selectedWallet: PropTypes.string, - onPress: PropTypes.func.isRequired, - handleLongPress: PropTypes.func.isRequired, - data: PropTypes.array, -}; - -export default WalletsCarousel; diff --git a/components/WalletsCarousel.tsx b/components/WalletsCarousel.tsx new file mode 100644 index 00000000000..7dacaa4301f --- /dev/null +++ b/components/WalletsCarousel.tsx @@ -0,0 +1,945 @@ +import React, { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useEffect } from 'react'; +import { + FlatList, + ImageBackground, + Platform, + Pressable, + StyleSheet, + Text, + View, + useWindowDimensions, + FlatListProps, + ListRenderItemInfo, + ViewStyle, +} from 'react-native'; +import Animated, { + Easing, + FadeIn, + FadeOut, + LinearTransition, + useAnimatedStyle, + useSharedValue, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import LinearGradient from 'react-native-linear-gradient'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet'; +import { MultisigHDWallet } from '../class/wallets/multisig-hd-wallet'; +import WalletGradient from '../class/wallet-gradient'; +import { useSizeClass, SizeClass } from '../blue_modules/sizeClass'; +import loc, { formatBalance, transactionTimeToReadable } from '../loc'; +import { BlurredBalanceView } from './BlurredBalanceView'; +import { withAlpha } from './color'; +import { useTheme } from './themes'; +import { Transaction, TWallet } from '../class/wallets/types'; +import { BlueSpacing10 } from './BlueSpacing'; +import { useLocale } from '@react-navigation/native'; + +export const WALLET_CAROUSEL_HEADER_WIDTH = 16; + +/** Base card body height at default Dynamic Type — grows with larger Dynamic Type, never shrinks below default. */ +export const WALLET_CARD_BASE_MIN_HEIGHT = 164; +/** Top inset above wallet cards in the horizontal home carousel. */ +export const WALLET_CAROUSEL_PADDING_TOP = 12; +/** Bottom inset so iOS card shadows (offset 4 + radius 8) are not clipped by the list row. */ +export const WALLET_CAROUSEL_PADDING_BOTTOM = 20; + +/** Scale layout metrics up for accessibility sizes; keep the design default when fontScale ≤ 1. */ +const scaleLayoutUp = (base: number, fontScale: number): number => Math.round(base * Math.max(1, fontScale)); + +export const getWalletCardMinHeight = (fontScale = 1): number => scaleLayoutUp(WALLET_CARD_BASE_MIN_HEIGHT, fontScale); + +export const getWalletCarouselHeight = (fontScale = 1): number => + scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale) + + getWalletCardMinHeight(fontScale) + + scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale); + +/** Default carousel row height at `fontScale` 1 — prefer `getWalletCarouselHeight(fontScale)` when layout depends on Dynamic Type. */ +export const WALLET_CAROUSEL_HEIGHT = getWalletCarouselHeight(1); + +/** Vertical gap between the wallet title/balance block and the latest-tx footer on carousel cards. */ +const WALLET_CARD_SECTION_GAP = 12; +const WALLET_CARD_TEXT_OPACITY = 0.85; + +export const getWalletCarouselItemWidth = (screenWidth: number) => Math.round(screenWidth * 0.82 > 375 ? 375 : screenWidth * 0.82); + +/** Shared pending-pill rule for on-chain vs Lightning/Ark cards. */ +export const walletHasPendingTransaction = (item: TWallet): boolean => { + const isLightningShaped = item.type === LightningCustodianWallet.type || item.type === LightningArkWallet.type; + // Lightning/Ark: `ispaid === false` alone is not pending (failed/refunded swaps stay in history). + if (isLightningShaped) { + return item.getTransactions().some((tx: any) => tx.ispaid === false && !tx.failed); + } + return item.getTransactions().some((tx: Transaction) => tx.confirmations === 0); +}; + +interface NewWalletPanelProps { + onPress: () => void; +} + +const nStyles = StyleSheet.create({ + container: { + borderRadius: 10, + minHeight: Platform.OS === 'ios' ? 164 : 181, + justifyContent: 'center', + alignItems: 'flex-start', + }, + addAWAllet: { + fontWeight: '600', + fontSize: 24, + marginBottom: 4, + }, + addLine: { + fontSize: 13, + }, + button: { + marginTop: 12, + backgroundColor: '#007AFF', + paddingHorizontal: 32, + paddingVertical: 12, + borderRadius: 8, + }, + buttonText: { + fontWeight: '500', + }, +}); + +const NewWalletPanel: React.FC = ({ onPress }) => { + const { colors } = useTheme(); + const { width } = useWindowDimensions(); + const itemWidth = getWalletCarouselItemWidth(width); + const { isLarge } = useSizeClass(); + const nStylesHooks = StyleSheet.create({ + container: isLarge + ? { + paddingHorizontal: 24, + marginVertical: 16, + } + : { paddingVertical: 16, paddingHorizontal: 24 }, + }); + + const scale = useSharedValue(1); + + const animatedScaleStyle = useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + })); + + const handlePressIn = useCallback(() => { + scale.value = withSpring(0.97, { damping: 14, stiffness: 180 }); + }, [scale]); + + const handlePressOut = useCallback(() => { + scale.value = withSpring(1, { damping: 14, stiffness: 180 }); + }, [scale]); + + return ( + [ + isLarge ? {} : { width: itemWidth * 1.2 }, + { + opacity: pressed ? 0.9 : 1.0, + }, + ]} + accessibilityRole="button" + accessibilityLabel={loc.wallets.list_create_a_wallet} + > + + {loc.wallets.list_create_a_wallet} + {loc.wallets.list_create_a_wallet_text} + + {loc.wallets.list_create_a_button} + + + + ); +}; + +interface WalletCarouselItemProps { + item: TWallet; + hideBalance: boolean; + onPress: (item: TWallet) => void; + handleLongPress?: () => void; + isSelectedWallet?: boolean; + customStyle?: ViewStyle; + horizontal?: boolean; + isPlaceHolder?: boolean; + searchQuery?: string; + renderHighlightedText?: (text: string, query: string) => React.ReactElement; + animationsEnabled?: boolean; + onPressIn?: () => void; + onPressOut?: () => void; + isNewWallet?: boolean; + isExiting?: boolean; + isDraggingActive?: boolean; + dragActiveScale?: number; + sizeVariant?: 'default' | 'compact'; +} + +const iStyles = StyleSheet.create({ + root: { paddingRight: 20 }, + rootLargeDevice: { marginVertical: 20 }, + grad: { + borderRadius: 12, + minHeight: 164, + overflow: 'hidden', + justifyContent: 'flex-end', + }, + gradCompact: { + borderRadius: 10, + minHeight: 132, + overflow: 'hidden', + justifyContent: 'flex-end', + }, + gradContent: { + padding: 15, + width: '100%', + }, + gradContentCompact: { + padding: 12, + }, + balanceContainer: { + minHeight: 40, + justifyContent: 'center', + }, + balanceContainerCompact: { + minHeight: 32, + justifyContent: 'center', + }, + image: { + width: 99, + height: 94, + position: 'absolute', + bottom: 0, + right: 0, + }, + imageCompact: { + width: 78, + height: 74, + }, + label: { + backgroundColor: 'transparent', + fontSize: 19, + }, + labelCompact: { + fontSize: 16, + }, + balance: { + backgroundColor: 'transparent', + fontWeight: 'bold', + fontSize: 36, + }, + balanceCompact: { + fontSize: 28, + }, + latestTx: { + backgroundColor: 'transparent', + fontSize: 13, + }, + latestTxCompact: { + fontSize: 12, + }, + latestTxTime: { + backgroundColor: 'transparent', + fontWeight: 'bold', + fontSize: 16, + }, + latestTxTimeCompact: { + fontSize: 14, + }, + shadowContainer: { + ...Platform.select({ + ios: { + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 25 / 100, + shadowRadius: 8, + borderRadius: 12, + }, + android: { + elevation: 8, + borderRadius: 12, + }, + }), + }, + shadowContainerCompact: { + ...Platform.select({ + ios: { + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 20 / 100, + shadowRadius: 6, + borderRadius: 10, + }, + android: { + elevation: 6, + borderRadius: 10, + }, + }), + }, +}); + +export const WalletCarouselItem: React.FC = ({ + item, + hideBalance, + onPress, + handleLongPress, + isSelectedWallet, + customStyle, + horizontal, + searchQuery, + renderHighlightedText, + animationsEnabled = true, + isPlaceHolder = false, + onPressIn, + onPressOut, + isNewWallet = false, + isExiting = false, + isDraggingActive = false, + dragActiveScale = 1.02, + sizeVariant = 'default', + }: WalletCarouselItemProps) => { + const walletLabel = item.getLabel ? item.getLabel() : ''; + const pressScale = useSharedValue(1.0); + const dragScale = useSharedValue(isDraggingActive ? dragActiveScale : 1.0); + const opacityValue = useSharedValue(isSelectedWallet === false ? 0.5 : 1.0); + const translateYValue = useSharedValue(isNewWallet ? 20 : 0); + const balanceOpacity = useSharedValue(1); + const balanceTranslateY = useSharedValue(0); + const { colors } = useTheme(); + const { width, fontScale } = useWindowDimensions(); + const itemWidth = getWalletCarouselItemWidth(width); + const { sizeClass } = useSizeClass(); + const isCompact = sizeVariant === 'compact'; + const { direction } = useLocale(); + const scaledCardStyles = useMemo( + () => ({ + grad: { minHeight: getWalletCardMinHeight(fontScale) }, + gradContent: { padding: scaleLayoutUp(15, fontScale) }, + balanceContainer: { minHeight: scaleLayoutUp(40, fontScale) }, + textSpacer: { height: scaleLayoutUp(WALLET_CARD_SECTION_GAP, fontScale) }, + label: { lineHeight: scaleLayoutUp(24, fontScale) }, + balance: { lineHeight: scaleLayoutUp(38, fontScale) }, + balanceCompact: { lineHeight: scaleLayoutUp(30, fontScale) }, + latestTx: { lineHeight: scaleLayoutUp(18, fontScale) }, + latestTxTime: { lineHeight: scaleLayoutUp(22, fontScale) }, + }), + [fontScale], + ); + const cardTextStyle = useMemo( + () => ({ + color: withAlpha(colors.inverseForegroundColor, WALLET_CARD_TEXT_OPACITY), + writingDirection: direction, + }), + [colors.inverseForegroundColor, direction], + ); + const previousBalance = useRef(undefined); + const balance = !hideBalance && formatBalance(Number(item.getBalance()), item.getPreferredBalanceUnit(), true); + const safeBalance = balance || undefined; + + const animatePressScale = useCallback( + (toValue: number) => { + pressScale.value = withSpring(toValue, { damping: 13, stiffness: 180, mass: 0.9 }); + }, + [pressScale], + ); + + useEffect(() => { + dragScale.value = withSpring(isDraggingActive ? dragActiveScale : 1, { damping: 16, stiffness: 200, mass: 1 }); + }, [isDraggingActive, dragActiveScale, dragScale]); + + useEffect(() => { + if (!animationsEnabled) return; + + const targetOpacity = isSelectedWallet === false ? 0.5 : 1.0; + opacityValue.value = withSpring(targetOpacity, { damping: 18, stiffness: 240 }); + }, [isSelectedWallet, opacityValue, animationsEnabled]); + + const onPressedIn = useCallback(() => { + if (animationsEnabled) { + animatePressScale(0.97); + } + if (onPressIn) onPressIn(); + }, [animatePressScale, animationsEnabled, onPressIn]); + + const onPressedOut = useCallback(() => { + if (animationsEnabled) { + animatePressScale(1.0); + } + if (onPressOut) onPressOut(); + }, [animatePressScale, animationsEnabled, onPressOut]); + + const handlePress = useCallback(() => { + onPress(item); + }, [item, onPress]); + + useEffect(() => { + if (isNewWallet && animationsEnabled) { + translateYValue.value = withTiming(0, { duration: 300 }); + opacityValue.value = withSpring(isSelectedWallet === false ? 0.5 : 1.0, { damping: 18, stiffness: 240 }); + } + }, [isNewWallet, animationsEnabled, translateYValue, opacityValue, isSelectedWallet]); + + useEffect(() => { + if (!animationsEnabled) { + previousBalance.current = safeBalance; + return; + } + + if (previousBalance.current !== undefined && previousBalance.current !== safeBalance) { + // Subtle currency-like transition on balance updates. + balanceOpacity.value = 0; + balanceTranslateY.value = 6; + balanceOpacity.value = withTiming(1, { duration: 180 }); + balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 }); + } + + previousBalance.current = safeBalance; + }, [safeBalance, animationsEnabled, balanceOpacity, balanceTranslateY]); + + useEffect(() => { + if (isExiting && animationsEnabled) { + translateYValue.value = withTiming(-20, { duration: 200 }); + opacityValue.value = withTiming(0, { duration: 200 }); + } + }, [isExiting, animationsEnabled, translateYValue, opacityValue]); + + const animatedCardStyle = useAnimatedStyle(() => ({ + opacity: opacityValue.value, + transform: [{ scale: pressScale.value * dragScale.value }, { translateY: translateYValue.value }], + })); + + const animatedBalanceStyle = useAnimatedStyle(() => ({ + opacity: balanceOpacity.value, + transform: [{ translateY: balanceTranslateY.value }], + })); + + let image; + switch (item.type) { + case LightningCustodianWallet.type: + case LightningArkWallet.type: + image = direction === 'rtl' ? require('../img/lnd-shape-rtl.png') : require('../img/lnd-shape.png'); + break; + case MultisigHDWallet.type: + image = direction === 'rtl' ? require('../img/vault-shape-rtl.png') : require('../img/vault-shape.png'); + break; + default: + image = direction === 'rtl' ? require('../img/btc-shape-rtl.png') : require('../img/btc-shape.png'); + } + + let latestTransactionText; + + if (item.getBalance() !== 0 && item.getLatestTransactionTime() === 0) { + latestTransactionText = loc.wallets.pull_to_refresh; + } else if (walletHasPendingTransaction(item)) { + latestTransactionText = loc.transactions.pending; + } else { + latestTransactionText = transactionTimeToReadable(item.getLatestTransactionTime()); + } + + return ( + + { + if (handleLongPress) handleLongPress(); + }} + onPress={handlePress} + delayHoverIn={0} + delayHoverOut={0} + > + + + + + {!isPlaceHolder && ( + <> + + {renderHighlightedText ? renderHighlightedText(walletLabel, searchQuery || '') : walletLabel} + + + {hideBalance ? ( + <> + + + + ) : ( + + {`${balance} `} + + )} + + + + {loc.wallets.list_latest_transaction} + + + {latestTransactionText} + + + )} + + + + + + ); +}; + +interface WalletsCarouselProps extends Partial> { + horizontal?: boolean; + isFlatList?: boolean; + selectedWallet?: string; + onPress: (item: TWallet) => void; + onNewWalletPress?: () => void; + handleLongPress?: () => void; + data: TWallet[]; + scrollEnabled?: boolean; + searchQuery?: string; + renderHighlightedText?: (text: string, query: string) => React.ReactElement; + animateChanges?: boolean; +} + +export type CarouselListRefType = FlatList; + +const styles = StyleSheet.create({ + listHeaderSeparator: { + width: WALLET_CAROUSEL_HEADER_WIDTH, + height: 20, + }, +}); + +const ListHeaderSeparator = () => ; + +const WalletsCarousel = forwardRef((props, ref) => { + const { + horizontal = true, + data, + handleLongPress, + onPress, + selectedWallet, + scrollEnabled = true, + onNewWalletPress, + searchQuery, + renderHighlightedText, + isFlatList = true, + animateChanges = false, + } = props; + + const { width, fontScale } = useWindowDimensions(); + const itemWidth = React.useMemo(() => getWalletCarouselItemWidth(width), [width]); + const snapInterval = React.useMemo(() => itemWidth, [itemWidth]); + const snapOffsets = React.useMemo(() => { + if (!horizontal) return undefined; + const cardsCount = data.length + (onNewWalletPress ? 1 : 0); + // Keep every card aligned with the first card's resting position. + return Array.from({ length: cardsCount }, (_, index) => index * snapInterval); + }, [horizontal, data.length, onNewWalletPress, snapInterval]); + const layoutTransition = useMemo(() => LinearTransition.duration(240).easing(Easing.inOut(Easing.quad)), []); + const enteringTransition = useMemo(() => FadeIn.duration(180), []); + const exitingTransition = useMemo(() => FadeOut.duration(150), []); + + const prevWalletIds = useRef([]); + const newWalletsMap = useRef>({}); + const lastAddedWalletId = useRef(null); + const hasFocusedRef = useRef(false); + const scrollTimeoutRef = useRef(null); + const isInitialMount = useRef(true); + + const flatListRef = useRef>(null); + const walletRefs = useRef>>({}); + + const { sizeClass } = useSizeClass(); + + useImperativeHandle(ref, (): any => { + if (isFlatList) { + return { + scrollToEnd: (params: { animated?: boolean | null | undefined } | undefined) => flatListRef.current?.scrollToEnd(params), + scrollToIndex: (params: { + animated?: boolean | null | undefined; + index: number; + viewOffset?: number | undefined; + viewPosition?: number | undefined; + }) => flatListRef.current?.scrollToIndex(params), + scrollToItem: (params: { + animated?: boolean | null | undefined; + item: any; + viewOffset?: number | undefined; + viewPosition?: number | undefined; + }) => flatListRef.current?.scrollToItem(params), + scrollToOffset: (params: { animated?: boolean | null | undefined; offset: number }) => flatListRef.current?.scrollToOffset(params), + recordInteraction: () => flatListRef.current?.recordInteraction(), + flashScrollIndicators: () => flatListRef.current?.flashScrollIndicators(), + getNativeScrollRef: () => flatListRef.current?.getNativeScrollRef(), + }; + } else { + // For non-FlatList mode, we'll return simpler methods to get/set information + // but not actually handle scrolling (leaving that to the parent drawer) + return { + scrollToEnd: () => console.debug('[WalletsCarousel] scrollToEnd not implemented for non-FlatList'), + scrollToIndex: () => console.debug('[WalletsCarousel] scrollToIndex not implemented for non-FlatList'), + scrollToItem: () => console.debug('[WalletsCarousel] scrollToItem not implemented for non-FlatList'), + scrollToOffset: () => console.debug('[WalletsCarousel] scrollToOffset not implemented for non-FlatList'), + recordInteraction: () => {}, + flashScrollIndicators: () => {}, + getNativeScrollRef: () => null, + // Add a method to get position information about a wallet + getWalletPosition: (walletId: string) => { + const walletRef = walletRefs.current[walletId]; + if (walletRef?.current) { + return new Promise<{ x: number; y: number; width: number; height: number }>(resolve => { + walletRef.current?.measure((x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => { + resolve({ x: pageX, y: pageY, width: widthVal, height: heightVal }); + }); + }); + } + return Promise.resolve(null); + }, + }; + } + }, [isFlatList]); + + useEffect(() => { + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; + }, []); + + useEffect(() => { + data.forEach(wallet => { + if (!walletRefs.current[wallet.getID()]) { + walletRefs.current[wallet.getID()] = { current: null }; + } + }); + }, [data]); + + const scrollToWalletById = useCallback( + (walletId: string, animated = true) => { + if (!walletId) return; + + console.debug('[WalletsCarousel] Attempting to scroll to wallet:', walletId); + + if (isFlatList && flatListRef.current) { + const walletIndex = data.findIndex(wallet => wallet.getID() === walletId); + if (walletIndex !== -1) { + try { + console.debug('[WalletsCarousel] Found wallet at index:', walletIndex, 'horizontal:', horizontal); + flatListRef.current.scrollToIndex({ + index: walletIndex, + animated, + viewPosition: 0.5, // Center the wallet in the view + }); + } catch (error) { + console.warn('[WalletsCarousel] Error scrolling to wallet:', error); + // Fallback: try scrolling to offset + // Use different measurement based on orientation + const itemSize = horizontal ? itemWidth : WALLET_CAROUSEL_HEIGHT; + flatListRef.current.scrollToOffset({ + offset: itemSize * walletIndex, + animated, + }); + } + } + } else if (!isFlatList) { + // For non-FlatList, just log the attempt + // The parent DrawerContentScrollView should handle this + const walletIndex = data.findIndex(wallet => wallet.getID() === walletId); + console.debug( + '[WalletsCarousel] Would scroll to wallet index:', + walletIndex, + 'but leaving scrolling to parent DrawerContentScrollView', + ); + } + }, + [data, isFlatList, itemWidth, horizontal], + ); + + useEffect(() => { + if (animateChanges) { + const currentWalletIds = data.map(wallet => wallet.getID()); + + // Skip auto-scrolling on initial mount + if (isInitialMount.current) { + isInitialMount.current = false; + prevWalletIds.current = currentWalletIds; + return; + } + + // Handle wallet additions + const addedWallets = currentWalletIds.filter(id => !prevWalletIds.current.includes(id)); + if (addedWallets.length > 0) { + // Track last added wallet for animations and scrolling + lastAddedWalletId.current = addedWallets[addedWallets.length - 1]; + + addedWallets.forEach(id => { + newWalletsMap.current[id] = true; + }); + + // Auto-scroll to new wallet after mount (no condition, always scroll) + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + + scrollTimeoutRef.current = setTimeout(() => { + // Add null check before calling scrollToWalletById + if (lastAddedWalletId.current !== null) { + scrollToWalletById(lastAddedWalletId.current, true); + } + }, 300); + } + + // Update refs for next comparison + prevWalletIds.current = currentWalletIds; + + // Clear animation states + if (addedWallets.length > 0) { + setTimeout(() => { + addedWallets.forEach(id => { + delete newWalletsMap.current[id]; + }); + lastAddedWalletId.current = null; + }, 2000); + } + } + }, [data, animateChanges, scrollToWalletById]); + + const onScrollToIndexFailed = (error: { averageItemLength: number; index: number }): void => { + console.debug('onScrollToIndexFailed', error); + flatListRef.current?.scrollToOffset({ offset: error.averageItemLength * error.index, animated: true }); + setTimeout(() => { + if (data.length !== 0 && flatListRef.current !== null) { + flatListRef.current.scrollToIndex({ index: error.index, animated: true }); + } + }, 100); + }; + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => { + if (!item) return null; + const content = ( + + ); + + if (!animateChanges) return content; + + return ( + + {content} + + ); + }, + [ + horizontal, + selectedWallet, + handleLongPress, + onPress, + searchQuery, + renderHighlightedText, + animateChanges, + layoutTransition, + enteringTransition, + exitingTransition, + ], + ); + + const keyExtractor = useCallback((item: TWallet, index: number) => (item?.getID ? item.getID() : index.toString()), []); + + const sliderHeight = getWalletCarouselHeight(fontScale); + + useEffect(() => { + return () => { + hasFocusedRef.current = false; + }; + }, []); + + const renderNonFlatListWallets = useCallback(() => { + return data.map(item => { + if (!item) return null; + + const content = ( + { + // Keep existing ref object in map + walletRefs.current[item.getID()] ??= { current: null }; + walletRefs.current[item.getID()].current = node; + }} + onLayout={() => { + if (walletRefs.current[item.getID()]?.current && newWalletsMap.current[item.getID()]) { + walletRefs.current[item.getID()].current?.measure( + (x: number, y: number, widthVal: number, heightVal: number, pageX: number, pageY: number) => { + console.debug(`[WalletsCarousel] New wallet ${item.getID()} positioned at y=${y}, pageY=${pageY}`); + }, + ); + } + }} + > + + + ); + + if (!animateChanges) return content; + + return ( + + {content} + + ); + }); + }, [ + data, + horizontal, + selectedWallet, + handleLongPress, + onPress, + props.searchQuery, + props.renderHighlightedText, + animateChanges, + layoutTransition, + enteringTransition, + exitingTransition, + ]); + + useEffect(() => { + // We check the current values inside the effect, but don't include them as dependencies + if (!isFlatList && lastAddedWalletId.current !== null && !isInitialMount.current) { + // Use a slightly longer delay to ensure the ScrollView has fully rendered + const scrollDelay = setTimeout(() => { + console.debug('[WalletsCarousel] Attempting delayed scroll to:', lastAddedWalletId.current); + if (lastAddedWalletId.current !== null) { + scrollToWalletById(lastAddedWalletId.current, true); + } + }, 500); + + return () => clearTimeout(scrollDelay); + } + }, [isFlatList, scrollToWalletById]); // Remove ref.current values from dependency array + + const cStyles = StyleSheet.create({ + content: { + paddingTop: scaleLayoutUp(WALLET_CAROUSEL_PADDING_TOP, fontScale), + paddingBottom: scaleLayoutUp(WALLET_CAROUSEL_PADDING_BOTTOM, fontScale), + }, + contentLargeScreen: { + paddingHorizontal: sizeClass === SizeClass.Large ? 16 : 12, + }, + }); + + return isFlatList ? ( + : null} + {...props} + // After `{...props}` so a caller `extraData` cannot drop `data` from the list's update signal. + extraData={[props.extraData, data, animateChanges, newWalletsMap.current, selectedWallet, lastAddedWalletId.current]} + /> + ) : ( + + {renderNonFlatListWallets()} + {onNewWalletPress && } + + ); +}); + +export default WalletsCarousel; diff --git a/components/WatchOnlyWarning.tsx b/components/WatchOnlyWarning.tsx new file mode 100644 index 00000000000..850a544f907 --- /dev/null +++ b/components/WatchOnlyWarning.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import { Image, Text, TouchableOpacity, View, StyleSheet, useColorScheme } from 'react-native'; +import Icon from './Icon'; +import loc from '../loc'; + +interface Props { + handleDismiss: () => void; +} + +const WatchOnlyWarning: React.FC = ({ handleDismiss }) => { + const colorScheme = useColorScheme(); + const isDark = colorScheme === 'dark'; + + return ( + + + + + {loc.transactions.watchOnlyWarningTitle} + + + + + + {loc.transactions.watchOnlyWarningDescription} + + ); +}; + +const styles = StyleSheet.create({ + container: { + padding: 16, + margin: 16, + borderRadius: 12, + }, + containerLight: { + backgroundColor: '#fc990e', + }, + containerDark: { + backgroundColor: '#7e4a05', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + title: { + flex: 1, + color: '#FFFFFF', + fontWeight: 'bold', + fontSize: 16, + marginLeft: 8, + textAlign: 'left', + }, + dismissButton: { + backgroundColor: 'rgba(255, 255, 255, 0.2)', + borderRadius: 14, + width: 28, + height: 28, + justifyContent: 'center', + alignItems: 'center', + marginLeft: 8, + }, + dismissIcon: { + width: 14, + height: 14, + resizeMode: 'contain', + }, + description: { + color: '#FFFFFF', + textAlign: 'left', + lineHeight: 20, + fontWeight: '600', + }, +}); + +export default WatchOnlyWarning; diff --git a/components/addresses/AddressItem.js b/components/addresses/AddressItem.js deleted file mode 100644 index 300ba0ee5b9..00000000000 --- a/components/addresses/AddressItem.js +++ /dev/null @@ -1,193 +0,0 @@ -import React, { useRef } from 'react'; -import { StyleSheet, Text, View } from 'react-native'; -import { useNavigation, useTheme } from '@react-navigation/native'; -import { ListItem } from 'react-native-elements'; -import PropTypes from 'prop-types'; -import { AddressTypeBadge } from './AddressTypeBadge'; -import loc, { formatBalance } from '../../loc'; -import TooltipMenu from '../TooltipMenu'; -import Clipboard from '@react-native-clipboard/clipboard'; -import Share from 'react-native-share'; - -const AddressItem = ({ item, balanceUnit, walletID, allowSignVerifyMessage }) => { - const { colors } = useTheme(); - - const hasTransactions = item.transactions > 0; - - const stylesHook = StyleSheet.create({ - container: { - borderBottomColor: colors.lightBorder, - backgroundColor: colors.elevated, - }, - list: { - color: colors.buttonTextColor, - }, - index: { - color: colors.alternativeTextColor, - }, - balance: { - color: colors.alternativeTextColor, - }, - address: { - color: hasTransactions ? colors.darkGray : colors.buttonTextColor, - }, - }); - - const { navigate } = useNavigation(); - - const menuRef = useRef(); - const navigateToReceive = () => { - menuRef.current?.dismissMenu(); - navigate('ReceiveDetailsRoot', { - screen: 'ReceiveDetails', - params: { - walletID, - address: item.address, - }, - }); - }; - - const navigateToSignVerify = () => { - menuRef.current?.dismissMenu(); - navigate('SignVerifyRoot', { - screen: 'SignVerify', - params: { - walletID, - address: item.address, - }, - }); - }; - - const balance = formatBalance(item.balance, balanceUnit, true); - - const handleCopyPress = () => { - Clipboard.setString(item.address); - }; - - const handleSharePress = () => { - Share.open({ message: item.address }).catch(error => console.log(error)); - }; - - const onToolTipPress = id => { - if (id === AddressItem.actionKeys.CopyToClipboard) { - handleCopyPress(); - } else if (id === AddressItem.actionKeys.Share) { - handleSharePress(); - } else if (id === AddressItem.actionKeys.SignVerify) { - navigateToSignVerify(); - } - }; - - const getAvailableActions = () => { - const actions = [ - { - id: AddressItem.actionKeys.CopyToClipboard, - text: loc.transactions.details_copy, - icon: AddressItem.actionIcons.Clipboard, - }, - { - id: AddressItem.actionKeys.Share, - text: loc.receive.details_share, - icon: AddressItem.actionIcons.Share, - }, - ]; - - if (allowSignVerifyMessage) { - actions.push({ - id: AddressItem.actionKeys.SignVerify, - text: loc.addresses.sign_title, - icon: AddressItem.actionIcons.Signature, - }); - } - - return actions; - }; - - const render = () => { - return ( - - - - - {item.index + 1}{' '} - {item.address} - - - {balance} - - - - - - {loc.addresses.transactions}: {item.transactions} - - - - - ); - }; - - return render(); -}; - -AddressItem.actionKeys = { - Share: 'share', - CopyToClipboard: 'copyToClipboard', - SignVerify: 'signVerify', -}; - -AddressItem.actionIcons = { - Signature: { - iconType: 'SYSTEM', - iconValue: 'signature', - }, - Share: { - iconType: 'SYSTEM', - iconValue: 'square.and.arrow.up', - }, - Clipboard: { - iconType: 'SYSTEM', - iconValue: 'doc.on.doc', - }, -}; - -const styles = StyleSheet.create({ - address: { - fontWeight: 'bold', - marginHorizontal: 40, - }, - index: { - fontSize: 15, - }, - balance: { - marginTop: 8, - marginLeft: 14, - }, - subtitle: { - flex: 1, - flexDirection: 'row', - justifyContent: 'space-between', - width: '100%', - }, -}); - -AddressItem.propTypes = { - item: PropTypes.shape({ - key: PropTypes.string, - index: PropTypes.number, - address: PropTypes.string, - isInternal: PropTypes.bool, - transactions: PropTypes.number, - balance: PropTypes.number, - }), - balanceUnit: PropTypes.string, -}; -export { AddressItem }; diff --git a/components/addresses/AddressItem.tsx b/components/addresses/AddressItem.tsx new file mode 100644 index 00000000000..65dbd12fa11 --- /dev/null +++ b/components/addresses/AddressItem.tsx @@ -0,0 +1,272 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import Clipboard from '@react-native-clipboard/clipboard'; +import { StyleSheet, Text, View } from 'react-native'; +import Share from 'react-native-share'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../../blue_modules/hapticFeedback'; +import confirm from '../../helpers/confirm'; +import { unlockWithBiometrics, useBiometrics } from '../../hooks/useBiometrics'; +import loc, { formatBalance } from '../../loc'; +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import presentAlert from '../Alert'; +import { useTheme } from '../themes'; +import { AddressTypeBadge } from './AddressTypeBadge'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { DetailViewStackParamList } from '../../navigation/DetailViewStackParamList'; +import { useStorage } from '../../hooks/context/useStorage'; +import ToolTipMenu from '../TooltipMenu'; +import { CommonToolTipActions } from '../../typings/CommonToolTipActions'; +import HighlightedText from '../HighlightedText'; +import { useSharedValue, withSpring, withTiming } from 'react-native-reanimated'; + +interface AddressItemProps { + item: any; + balanceUnit: BitcoinUnit; + walletID: string; + allowSignVerifyMessage: boolean; + onPress?: () => void; // example: ManageWallets uses this + searchQuery?: string; + renderHighlightedText?: (text: string, query: string) => React.ReactElement; +} + +type NavigationProps = NativeStackNavigationProp; + +const AddressItem = ({ + item, + balanceUnit, + walletID, + allowSignVerifyMessage, + onPress, + searchQuery = '', + renderHighlightedText, +}: AddressItemProps) => { + const { wallets } = useStorage(); + const { colors, dark } = useTheme(); + const { isBiometricUseCapableAndEnabled } = useBiometrics(); + const balanceOpacity = useSharedValue(1); + const balanceTranslateY = useSharedValue(0); + const previousBalance = useRef(undefined); + + const hasTransactions = item.transactions > 0; + + const stylesHook = StyleSheet.create({ + container: { + borderBottomColor: colors.lightBorder, + backgroundColor: colors.elevated, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + + index: { + color: colors.alternativeTextColor, + }, + balance: { + color: colors.alternativeTextColor, + }, + address: { + color: dark ? colors.foregroundColor : colors.darkGray, + }, + }); + + const { navigate } = useNavigation(); + + const navigateToReceive = useCallback(() => { + if (onPress) { + onPress(); + } else { + navigate('ReceiveDetails', { + walletID, + address: item.address, + }); + } + }, [navigate, walletID, item.address, onPress]); + + const navigateToSignVerify = useCallback(() => { + navigate('SignVerifyRoot', { + screen: 'SignVerify', + params: { + walletID, + address: item.address, + }, + }); + }, [navigate, walletID, item.address]); + + const menuActions = useMemo( + () => [ + CommonToolTipActions.CopyTXID, + CommonToolTipActions.Share, + { + ...CommonToolTipActions.SignVerify, + hidden: !allowSignVerifyMessage, + }, + { + ...CommonToolTipActions.ExportPrivateKey, + hidden: !allowSignVerifyMessage, + }, + ], + [allowSignVerifyMessage], + ); + + const balance = formatBalance(item.balance, balanceUnit, true); + + useEffect(() => { + if (previousBalance.current !== undefined && previousBalance.current !== balance) { + balanceOpacity.value = 0; + balanceTranslateY.value = 6; + balanceOpacity.value = withTiming(1, { duration: 180 }); + balanceTranslateY.value = withSpring(0, { damping: 16, stiffness: 220 }); + } + + previousBalance.current = balance; + }, [balance, balanceOpacity, balanceTranslateY]); + + const handleCopyPress = useCallback(() => { + Clipboard.setString(item.address); + }, [item.address]); + + const handleSharePress = useCallback(() => { + Share.open({ message: item.address }).catch(error => console.log(error)); + }, [item.address]); + + const handleCopyPrivkeyPress = useCallback(() => { + const wallet = wallets.find(w => w.getID() === walletID); + if (!wallet) { + presentAlert({ message: 'Internal error: cant find wallet' }); + return; + } + + try { + const wif = wallet._getWIFbyAddress(item.address); + if (!wif) { + presentAlert({ message: 'Internal error: cant get WIF from the wallet' }); + return; + } + triggerHapticFeedback(HapticFeedbackTypes.Selection); + Clipboard.setString(wif); + } catch (error: any) { + presentAlert({ message: error.message }); + } + }, [wallets, walletID, item.address]); + + const onToolTipPress = useCallback( + async (id: string) => { + if (id === CommonToolTipActions.CopyTXID.id) { + handleCopyPress(); + } else if (id === CommonToolTipActions.Share.id) { + handleSharePress(); + } else if (id === CommonToolTipActions.SignVerify.id) { + navigateToSignVerify(); + } else if (id === CommonToolTipActions.ExportPrivateKey.id) { + if (await confirm(loc.addresses.sensitive_private_key)) { + if (await isBiometricUseCapableAndEnabled()) { + if (!(await unlockWithBiometrics())) { + return; + } + } + handleCopyPrivkeyPress(); + } + } + }, + [handleCopyPress, handleSharePress, navigateToSignVerify, handleCopyPrivkeyPress, isBiometricUseCapableAndEnabled], + ); + + // Render address with highlighting if a search query is provided + const renderAddressContent = () => { + if (searchQuery && searchQuery.length > 0) { + if (renderHighlightedText) { + return renderHighlightedText(item.address, searchQuery); + } + return ( + + ); + } + + return ( + + {item.address} + + ); + }; + + return ( + + + + + {item.index} + + + {renderAddressContent()} + {balance} + + + + + + {loc.addresses.transactions}: {item.transactions ?? 0} + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: StyleSheet.hairlineWidth, + }, + address: { + fontWeight: 'bold', + marginHorizontal: 4, + }, + tooltipButton: { + width: '100%', + alignSelf: 'stretch', + }, + index: { + fontSize: 15, + fontWeight: '600', + }, + balance: { + marginTop: 6, + fontWeight: '600', + }, + row: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + }, + leftSection: { + marginRight: 10, + paddingTop: 2, + }, + middleSection: { + flex: 1, + paddingRight: 12, + }, + rightContainer: { + justifyContent: 'center', + alignItems: 'flex-end', + minWidth: 96, + paddingLeft: 8, + }, +}); + +export { AddressItem }; diff --git a/components/addresses/AddressTypeBadge.js b/components/addresses/AddressTypeBadge.js deleted file mode 100644 index 8b60d1ad2a4..00000000000 --- a/components/addresses/AddressTypeBadge.js +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useTheme } from '@react-navigation/native'; -import { StyleSheet, View, Text } from 'react-native'; -import loc, { formatStringAddTwoWhiteSpaces } from '../../loc'; - -const styles = StyleSheet.create({ - container: { - paddingVertical: 4, - paddingHorizontal: 10, - borderRadius: 20, - alignSelf: 'flex-end', - }, - badgeText: { - fontSize: 12, - textAlign: 'center', - }, -}); - -const AddressTypeBadge = ({ isInternal, hasTransactions }) => { - const { colors } = useTheme(); - - const stylesHook = StyleSheet.create({ - changeBadge: { backgroundColor: colors.changeBackground }, - receiveBadge: { backgroundColor: colors.receiveBackground }, - usedBadge: { backgroundColor: colors.buttonDisabledBackgroundColor }, - changeText: { color: colors.changeText }, - receiveText: { color: colors.receiveText }, - usedText: { color: colors.alternativeTextColor }, - }); - - const badgeLabel = hasTransactions - ? loc.addresses.type_used - : isInternal - ? formatStringAddTwoWhiteSpaces(loc.addresses.type_change) - : formatStringAddTwoWhiteSpaces(loc.addresses.type_receive); - - // eslint-disable-next-line prettier/prettier - const badgeStyle = hasTransactions - ? stylesHook.usedBadge - : isInternal - ? stylesHook.changeBadge - : stylesHook.receiveBadge; - - // eslint-disable-next-line prettier/prettier - const textStyle = hasTransactions - ? stylesHook.usedText - : isInternal - ? stylesHook.changeText - : stylesHook.receiveText; - - return ( - - {badgeLabel} - - ); -}; - -AddressTypeBadge.propTypes = { - isInternal: PropTypes.bool, - hasTransactions: PropTypes.bool, -}; - -export { AddressTypeBadge }; diff --git a/components/addresses/AddressTypeBadge.tsx b/components/addresses/AddressTypeBadge.tsx new file mode 100644 index 00000000000..05b8f647d1c --- /dev/null +++ b/components/addresses/AddressTypeBadge.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import loc, { formatStringAddTwoWhiteSpaces } from '../../loc'; +import { useTheme } from '../themes'; + +type Props = { isInternal: boolean; hasTransactions: boolean }; + +export const AddressTypeBadge: React.FC = ({ isInternal, hasTransactions }) => { + const { colors } = useTheme(); + + const stylesHook = StyleSheet.create({ + changeBadge: { backgroundColor: colors.changeBackground }, + receiveBadge: { backgroundColor: colors.receiveBackground }, + usedBadge: { backgroundColor: colors.buttonDisabledBackgroundColor }, + changeText: { color: colors.changeText }, + receiveText: { color: colors.receiveText }, + usedText: { color: colors.alternativeTextColor }, + }); + + const badgeLabel = hasTransactions + ? loc.addresses.type_used + : isInternal + ? formatStringAddTwoWhiteSpaces(loc.addresses.type_change) + : formatStringAddTwoWhiteSpaces(loc.addresses.type_receive); + + // eslint-disable-next-line prettier/prettier + const badgeStyle = hasTransactions + ? stylesHook.usedBadge + : isInternal + ? stylesHook.changeBadge + : stylesHook.receiveBadge; + + // eslint-disable-next-line prettier/prettier + const textStyle = hasTransactions + ? stylesHook.usedText + : isInternal + ? stylesHook.changeText + : stylesHook.receiveText; + + return ( + + {badgeLabel} + + ); +}; + +const styles = StyleSheet.create({ + container: { + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 20, + alignSelf: 'flex-end', + }, + badgeText: { + fontSize: 12, + textAlign: 'center', + }, +}); diff --git a/components/addresses/AddressTypeTabs.js b/components/addresses/AddressTypeTabs.js deleted file mode 100644 index a7a2da63b0e..00000000000 --- a/components/addresses/AddressTypeTabs.js +++ /dev/null @@ -1,96 +0,0 @@ -import { useTheme } from '@react-navigation/native'; -import React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import loc from '../../loc'; - -export const TABS = { - EXTERNAL: 'receive', - INTERNAL: 'change', -}; - -const AddressTypeTabs = ({ currentTab, setCurrentTab }) => { - const { colors } = useTheme(); - - const stylesHook = StyleSheet.create({ - activeTab: { - backgroundColor: colors.modal, - }, - activeText: { - fontWeight: 'bold', - color: colors.foregroundColor, - }, - inactiveTab: { - fontWeight: 'normal', - color: colors.foregroundColor, - }, - backTabs: { - backgroundColor: colors.buttonDisabledBackgroundColor, - }, - }); - - const tabs = Object.entries(TABS).map(([key, value]) => { - return { - key, - value, - name: loc.addresses[`type_${value}`], - }; - }); - - const changeToTab = tabKey => { - if (tabKey in TABS) { - setCurrentTab(TABS[tabKey]); - } - }; - - const render = () => { - const tabsButtons = tabs.map(tab => { - const isActive = tab.value === currentTab; - - const tabStyle = isActive ? stylesHook.activeTab : stylesHook.inactiveTab; - const textStyle = isActive ? stylesHook.activeText : stylesHook.inactiveTab; - - return ( - changeToTab(tab.key)} style={[styles.tab, tabStyle]}> - changeToTab(tab.key)} style={textStyle}> - {tab.name} - - - ); - }); - - return ( - - - {tabsButtons} - - - ); - }; - - return render(); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - flexDirection: 'row', - justifyContent: 'center', - }, - backTabs: { - padding: 4, - marginVertical: 8, - borderRadius: 8, - }, - tabs: { - flex: 1, - flexDirection: 'row', - justifyContent: 'center', - }, - tab: { - borderRadius: 6, - paddingVertical: 8, - paddingHorizontal: 16, - }, -}); - -export { AddressTypeTabs }; diff --git a/components/color.ts b/components/color.ts new file mode 100644 index 00000000000..68994e340d8 --- /dev/null +++ b/components/color.ts @@ -0,0 +1,32 @@ +const HEX6_RE = /^[0-9a-fA-F]{6}$/; + +/** Apply alpha to `#RGB`/`#RRGGBB`/`#RRGGBBAA` (input alpha ignored). Other formats returned unchanged. */ +export const withAlpha = (color: string, alpha: number): string => { + const a = Math.max(0, Math.min(1, alpha)); + + if (color.startsWith('#')) { + const hex = color.slice(1); + const normalized = + hex.length === 3 + ? hex + .split('') + .map(c => c + c) + .join('') + : hex.length === 8 + ? hex.slice(0, 6) + : hex; + + if (normalized.length === 6 && HEX6_RE.test(normalized)) { + const r = parseInt(normalized.slice(0, 2), 16); + const g = parseInt(normalized.slice(2, 4), 16); + const b = parseInt(normalized.slice(4, 6), 16); + return `rgba(${r},${g},${b},${a})`; + } + } + + if (__DEV__) { + console.warn(`[withAlpha] unsupported color format: ${String(color)}`); + } + + return color; +}; diff --git a/components/handoff.tsx b/components/handoff.tsx deleted file mode 100644 index cd8abdadf89..00000000000 --- a/components/handoff.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import React, { useContext } from 'react'; -// @ts-ignore: react-native-handoff is not in the type definition -import Handoff from 'react-native-handoff'; -import { BlueStorageContext } from '../blue_modules/storage-context'; - -interface HandoffComponentProps { - url?: string; -} - -interface HandoffComponentWithActivityTypes extends React.FC { - activityTypes: { - ReceiveOnchain: string; - Xpub: string; - ViewInBlockExplorer: string; - }; -} - -const HandoffComponent: HandoffComponentWithActivityTypes = props => { - const { isHandOffUseEnabled } = useContext(BlueStorageContext); - - if (isHandOffUseEnabled) { - return ; - } - return null; -}; - -const activityTypes = { - ReceiveOnchain: 'io.bluewallet.bluewallet.receiveonchain', - Xpub: 'io.bluewallet.bluewallet.xpub', - ViewInBlockExplorer: 'io.bluewallet.bluewallet.blockexplorer', -}; - -HandoffComponent.activityTypes = activityTypes; - -export default HandoffComponent; diff --git a/components/headerMenuOptions.tsx b/components/headerMenuOptions.tsx new file mode 100644 index 00000000000..6788bad0eb3 --- /dev/null +++ b/components/headerMenuOptions.tsx @@ -0,0 +1,53 @@ +import type { NativeStackNavigationOptions } from '@react-navigation/native-stack'; +import React from 'react'; + +import HeaderMenuButton from './HeaderMenuButton'; +import { mapActionGroupsToNativeHeaderMenuItems, mapActionsToNativeHeaderMenuItems } from './nativeHeaderMenuItems'; +import { Action } from './types'; + +type HeaderRightRenderer = NonNullable; +type HeaderItemsGetter = NonNullable; + +type HeaderMenuOptions = { + headerRight: HeaderRightRenderer; + unstable_headerRightItems: HeaderItemsGetter; +}; + +type HeaderMenuOptionsParams = { + actions: Action[] | Action[][]; + onPressMenuItem: (id: string) => void; + disabled?: boolean; + preserveGroups?: boolean; + identifier?: string; + title?: string; +}; + +export const createEllipsisHeaderMenuOptions = ({ + actions, + onPressMenuItem, + disabled = false, + preserveGroups = false, + identifier = 'HeaderMenuButton', + title = '', +}: HeaderMenuOptionsParams): HeaderMenuOptions => { + const hasGroups = Array.isArray(actions[0]); + const nativeHeaderMenuItems = hasGroups + ? mapActionGroupsToNativeHeaderMenuItems(actions as Action[][], onPressMenuItem, preserveGroups) + : mapActionsToNativeHeaderMenuItems(actions as Action[], onPressMenuItem); + + return { + headerRight: () => React.createElement(HeaderMenuButton, { onPressMenuItem, actions, disabled }), + unstable_headerRightItems: () => [ + { + type: 'menu', + label: title, + icon: { type: 'sfSymbol', name: 'ellipsis' }, + identifier, + menu: { + title, + items: nativeHeaderMenuItems, + }, + }, + ], + }; +}; diff --git a/components/icons/PlusIcon.js b/components/icons/PlusIcon.js deleted file mode 100644 index 122349e5a1f..00000000000 --- a/components/icons/PlusIcon.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { StyleSheet } from 'react-native'; -import { Avatar } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - ball: { - width: 30, - height: 30, - borderRadius: 15, - }, -}); - -const PlusIcon = props => { - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - ball: { - backgroundColor: colors.buttonBackgroundColor, - }, - }); - - return ( - - ); -}; - -export default PlusIcon; diff --git a/components/icons/SettingsButton.tsx b/components/icons/SettingsButton.tsx new file mode 100644 index 00000000000..055720e8921 --- /dev/null +++ b/components/icons/SettingsButton.tsx @@ -0,0 +1,65 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useCallback, useMemo } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Icon from '../Icon'; +import { useTheme } from '../themes'; +import loc from '../../loc'; +import ToolTipMenu from '../TooltipMenu'; +import { CommonToolTipActions } from '../../typings/CommonToolTipActions'; + +const SettingsButton = () => { + const { colors } = useTheme(); + const { navigate } = useNavigation(); + const onPress = () => { + navigate('Settings'); + }; + + const onPressMenuItem = useCallback( + (menuItem: string) => { + switch (menuItem) { + case CommonToolTipActions.ManageWallet.id: + navigate('ManageWallets'); + break; + default: + break; + } + }, + [navigate], + ); + + const actions = useMemo(() => [CommonToolTipActions.ManageWallet], []); + return ( + + + + + + ); +}; + +export default SettingsButton; + +const style = StyleSheet.create({ + buttonStyle: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + iconContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, +}); diff --git a/components/icons/TransactionExpiredIcon.js b/components/icons/TransactionExpiredIcon.js deleted file mode 100644 index 37cc9c7f73e..00000000000 --- a/components/icons/TransactionExpiredIcon.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballOutgoingExpired: { - width: 30, - height: 30, - borderRadius: 15, - justifyContent: 'center', - }, - icon: { - left: 0, - top: 0, - }, -}); - -const TransactionExpiredIcon = props => { - const { colors } = useTheme(); - const stylesHooks = StyleSheet.create({ - ballOutgoingExpired: { - backgroundColor: colors.ballOutgoingExpired, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionExpiredIcon; diff --git a/components/icons/TransactionExpiredIcon.tsx b/components/icons/TransactionExpiredIcon.tsx new file mode 100644 index 00000000000..d82f2e78274 --- /dev/null +++ b/components/icons/TransactionExpiredIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballOutgoingExpired: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionExpiredIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesHooks = StyleSheet.create({ + ballOutgoingExpired: { + backgroundColor: colors.ballOutgoingExpired, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionExpiredIcon; diff --git a/components/icons/TransactionIncomingIcon.js b/components/icons/TransactionIncomingIcon.js deleted file mode 100644 index 65aa45eea02..00000000000 --- a/components/icons/TransactionIncomingIcon.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballIncoming: { - width: 30, - height: 30, - borderRadius: 15, - transform: [{ rotate: '-45deg' }], - justifyContent: 'center', - }, -}); - -const TransactionIncomingIcon = props => { - const { colors } = useTheme(); - const stylesHooks = StyleSheet.create({ - ballIncoming: { - backgroundColor: colors.ballReceive, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionIncomingIcon; diff --git a/components/icons/TransactionIncomingIcon.tsx b/components/icons/TransactionIncomingIcon.tsx new file mode 100644 index 00000000000..4b2b5495576 --- /dev/null +++ b/components/icons/TransactionIncomingIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballIncoming: { + width: 30, + height: 30, + borderRadius: 15, + transform: [{ rotate: '-45deg' }], + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionIncomingIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesHooks = StyleSheet.create({ + ballIncoming: { + backgroundColor: colors.ballReceive, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionIncomingIcon; diff --git a/components/icons/TransactionOffchainIcon.js b/components/icons/TransactionOffchainIcon.js deleted file mode 100644 index a1f78e9c8b4..00000000000 --- a/components/icons/TransactionOffchainIcon.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballOutgoingWithoutRotate: { - width: 30, - height: 30, - borderRadius: 15, - }, - icon: { - left: 0, - marginTop: 6, - }, -}); - -const TransactionOffchainIcon = props => { - const { colors } = useTheme(); - const stylesHooks = StyleSheet.create({ - ballOutgoingWithoutRotate: { - backgroundColor: colors.ballOutgoing, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionOffchainIcon; diff --git a/components/icons/TransactionOffchainIcon.tsx b/components/icons/TransactionOffchainIcon.tsx new file mode 100644 index 00000000000..56a3ffcf623 --- /dev/null +++ b/components/icons/TransactionOffchainIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballOutgoingWithoutRotate: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionOffchainIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesHooks = StyleSheet.create({ + ballOutgoingWithoutRotate: { + backgroundColor: colors.ballOutgoing, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionOffchainIcon; diff --git a/components/icons/TransactionOffchainIncomingIcon.js b/components/icons/TransactionOffchainIncomingIcon.js deleted file mode 100644 index d78359fcf48..00000000000 --- a/components/icons/TransactionOffchainIncomingIcon.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballIncomingWithoutRotate: { - width: 30, - height: 30, - borderRadius: 15, - }, - icon: { - left: 0, - marginTop: 6, - }, -}); - -const TransactionOffchainIncomingIcon = props => { - const { colors } = useTheme(); - const stylesHooks = StyleSheet.create({ - ballIncomingWithoutRotate: { - backgroundColor: colors.ballReceive, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionOffchainIncomingIcon; diff --git a/components/icons/TransactionOffchainIncomingIcon.tsx b/components/icons/TransactionOffchainIncomingIcon.tsx new file mode 100644 index 00000000000..f077ec54aab --- /dev/null +++ b/components/icons/TransactionOffchainIncomingIcon.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballIncomingWithoutRotate: { + width: 30, + height: 30, + borderRadius: 15, + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionOffchainIncomingIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesHooks = StyleSheet.create({ + ballIncomingWithoutRotate: { + backgroundColor: colors.ballReceive, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionOffchainIncomingIcon; diff --git a/components/icons/TransactionOnchainIcon.js b/components/icons/TransactionOnchainIcon.js deleted file mode 100644 index 785e425e1c0..00000000000 --- a/components/icons/TransactionOnchainIcon.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballIncoming: { - width: 30, - height: 30, - borderRadius: 15, - transform: [{ rotate: '-45deg' }], - justifyContent: 'center', - }, - icon: { - left: 0, - top: 0, - transform: [{ rotate: '-45deg' }], - }, -}); - -const TransactionOnchainIcon = props => { - const { colors } = useTheme(); - const stylesBlueIconHooks = StyleSheet.create({ - ballIncoming: { - backgroundColor: colors.ballReceive, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionOnchainIcon; diff --git a/components/icons/TransactionOnchainIcon.tsx b/components/icons/TransactionOnchainIcon.tsx new file mode 100644 index 00000000000..ae4d375f729 --- /dev/null +++ b/components/icons/TransactionOnchainIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballIncoming: { + width: 30, + height: 30, + borderRadius: 15, + transform: [{ rotate: '-45deg' }], + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionOnchainIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesBlueIconHooks = StyleSheet.create({ + ballIncoming: { + backgroundColor: colors.ballReceive, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionOnchainIcon; diff --git a/components/icons/TransactionOutgoingIcon.js b/components/icons/TransactionOutgoingIcon.js deleted file mode 100644 index 2ae3c92bd8f..00000000000 --- a/components/icons/TransactionOutgoingIcon.js +++ /dev/null @@ -1,36 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ballOutgoing: { - width: 30, - height: 30, - borderRadius: 15, - transform: [{ rotate: '225deg' }], - justifyContent: 'center', - }, -}); - -const TransactionOutgoingIcon = props => { - const { colors } = useTheme(); - const stylesBlueIconHooks = StyleSheet.create({ - ballOutgoing: { - backgroundColor: colors.ballOutgoing, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionOutgoingIcon; diff --git a/components/icons/TransactionOutgoingIcon.tsx b/components/icons/TransactionOutgoingIcon.tsx new file mode 100644 index 00000000000..17de2efde35 --- /dev/null +++ b/components/icons/TransactionOutgoingIcon.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { StyleSheet, View, ViewStyle } from 'react-native'; +import Icon from '../Icon'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + } as ViewStyle, + ballOutgoing: { + width: 30, + height: 30, + borderRadius: 15, + transform: [{ rotate: '225deg' }], + justifyContent: 'center', + alignItems: 'center', + } as ViewStyle, +}); + +const TransactionOutgoingIcon: React.FC = () => { + const { colors } = useTheme(); + + const stylesBlueIconHooks = StyleSheet.create({ + ballOutgoing: { + backgroundColor: colors.ballOutgoing, + }, + }); + + return ( + + + + + + ); +}; + +export default TransactionOutgoingIcon; diff --git a/components/icons/TransactionPendingIcon.js b/components/icons/TransactionPendingIcon.js deleted file mode 100644 index ddd5c9c6398..00000000000 --- a/components/icons/TransactionPendingIcon.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import { StyleSheet, View } from 'react-native'; -import { Icon } from 'react-native-elements'; -import { useTheme } from '@react-navigation/native'; - -const styles = StyleSheet.create({ - boxIncoming: { - position: 'relative', - }, - ball: { - width: 30, - height: 30, - borderRadius: 15, - }, - icon: { - left: 0, - top: 7, - }, -}); - -const TransactionPendingIcon = props => { - const { colors } = useTheme(); - const stylesHook = StyleSheet.create({ - ball: { - backgroundColor: colors.buttonBackgroundColor, - }, - }); - - return ( - - - - - - ); -}; - -export default TransactionPendingIcon; diff --git a/components/icons/TransactionPendingIcon.tsx b/components/icons/TransactionPendingIcon.tsx new file mode 100644 index 00000000000..b2289153333 --- /dev/null +++ b/components/icons/TransactionPendingIcon.tsx @@ -0,0 +1,54 @@ +import React, { useRef } from 'react'; +import { StyleSheet, View } from 'react-native'; +import LottieView from 'lottie-react-native'; + +import { useTheme } from '../themes'; + +const styles = StyleSheet.create({ + boxIncoming: { + position: 'relative', + }, + ball: { + width: 32, + height: 32, + borderRadius: 16, + justifyContent: 'center', + alignItems: 'center', + }, + lottie: { + width: 20, + height: 20, + alignSelf: 'center', + }, +}); + +const TransactionPendingIcon: React.FC = () => { + const { colors } = useTheme(); + const lottieRef = useRef(null); + + const stylesHook = StyleSheet.create({ + ball: { + backgroundColor: colors.transactionPendingIconBackground, + }, + }); + + const pendingAnimation = require('../../img/pending.json'); + + return ( + + + + + + ); +}; + +export default TransactionPendingIcon; diff --git a/components/nativeHeaderMenuItems.ts b/components/nativeHeaderMenuItems.ts new file mode 100644 index 00000000000..9fa5459d614 --- /dev/null +++ b/components/nativeHeaderMenuItems.ts @@ -0,0 +1,88 @@ +import { Platform } from 'react-native'; +import { Action } from './types'; +import type { NativeStackHeaderItemMenuAction, NativeStackHeaderItemMenuSubmenu } from '@react-navigation/native-stack'; + +type NativeHeaderMenuAction = NativeStackHeaderItemMenuAction & { identifier?: string }; +type NativeHeaderMenuSubmenu = NativeStackHeaderItemMenuSubmenu & { identifier?: string; items: NativeHeaderMenuItem[] }; +type NativeHeaderMenuItem = NativeHeaderMenuAction | NativeHeaderMenuSubmenu; + +const toNativeState = (menuState: Action['menuState']): 'on' | 'off' | 'mixed' | undefined => { + if (menuState === undefined) { + return undefined; + } + if (menuState === 'mixed') { + return 'mixed'; + } + return menuState ? 'on' : 'off'; +}; + +const toNativeIcon = (iconValue?: string): { type: 'sfSymbol'; name: string } | undefined => { + if (!iconValue) { + return undefined; + } + + // Android drawable names (e.g. ic_menu_camera) are invalid SF Symbols. + if (iconValue.startsWith('ic_')) { + return undefined; + } + + return { type: 'sfSymbol', name: iconValue }; +}; + +const mapActionToNativeItem = (action: Action, onPressMenuItem: (id: string) => void): NativeHeaderMenuItem | null => { + if (!action || action.hidden || action.id === undefined || action.id === null) { + return null; + } + + const id = String(action.id); + const subItems = (action.subactions ?? []) + .map(subaction => mapActionToNativeItem(subaction, onPressMenuItem)) + .filter((item): item is NativeHeaderMenuItem => item !== null); + + if (subItems.length > 0) { + return { + type: 'submenu', + label: action.text, + ...(Platform.OS === 'ios' ? { identifier: id } : {}), + inline: action.displayInline, + items: subItems, + }; + } + + return { + type: 'action', + label: action.text, + ...(Platform.OS === 'ios' ? { identifier: id } : {}), + description: action.subtitle, + icon: toNativeIcon(action.icon?.iconValue ?? action.image) as NativeStackHeaderItemMenuAction['icon'], + onPress: () => onPressMenuItem(id), + state: toNativeState(action.menuState), + destructive: Boolean(action.destructive), + disabled: Boolean(action.disabled), + }; +}; + +export const mapActionsToNativeHeaderMenuItems = (actions: Action[], onPressMenuItem: (id: string) => void): NativeHeaderMenuItem[] => { + return actions + .map(action => mapActionToNativeItem(action, onPressMenuItem)) + .filter((item): item is NativeHeaderMenuItem => item !== null); +}; + +export const mapActionGroupsToNativeHeaderMenuItems = ( + actionGroups: Action[][], + onPressMenuItem: (id: string) => void, + preserveGroups = false, +): NativeHeaderMenuItem[] => { + const groups = actionGroups.map(group => mapActionsToNativeHeaderMenuItems(group, onPressMenuItem)).filter(group => group.length > 0); + + if (!preserveGroups) { + return groups.flat(); + } + + return groups.map(items => ({ + type: 'submenu', + label: '', + inline: true, + items, + })); +}; diff --git a/components/navigationStyle.tsx b/components/navigationStyle.tsx index f312242e1ca..5e3878fec59 100644 --- a/components/navigationStyle.tsx +++ b/components/navigationStyle.tsx @@ -1,7 +1,9 @@ +import { NativeStackNavigationOptions } from '@react-navigation/native-stack'; import React from 'react'; -import { Image, Keyboard, TouchableOpacity, StyleSheet } from 'react-native'; -import { Theme } from './themes'; +import { Image, Keyboard, Platform, StyleSheet, TouchableOpacity } from 'react-native'; + import loc from '../loc'; +import { Theme } from './themes'; const styles = StyleSheet.create({ button: { @@ -10,122 +12,168 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + buttonFormSheet: { + justifyContent: 'center', + alignItems: 'center', + width: 30, + height: 30, + borderRadius: 15, + marginLeft: 22, + }, }); -type NavigationOptions = { - headerStyle?: { - borderBottomWidth: number; - elevation: number; - shadowOpacity?: number; - shadowOffset: { height?: number; width?: number }; - }; - headerTitleStyle?: { - fontWeight: string; - color: string; - }; - headerLeft?: (() => React.ReactElement) | null; - headerRight?: (() => React.ReactElement) | null; - headerBackTitleVisible?: false; - headerTintColor?: string; - title?: string; +enum CloseButtonPosition { + None = 'None', + Left = 'Left', + Right = 'Right', +} + +type OptionsFormatter = ( + options: NativeStackNavigationOptions, + deps: { theme: Theme; navigation: any; route: any }, +) => NativeStackNavigationOptions; + +type RouteParamHeaderOptions = { + headerLeft?: boolean; + headerRight?: boolean; + headerBackVisible?: boolean; + statusBarStyle?: boolean; }; -type OptionsFormatter = (options: NavigationOptions, deps: { theme: Theme; navigation: any; route: any }) => NavigationOptions; +export type NavigationOptionsGetter = (theme: Theme) => (deps: { navigation: any; route: any }) => NativeStackNavigationOptions; -export type NavigationOptionsGetter = (theme: Theme) => (deps: { navigation: any; route: any }) => NavigationOptions; +const withRouteParamHeaderOptions = + (config: RouteParamHeaderOptions): OptionsFormatter => + (options, { route }) => { + const routeParams = route?.params ?? {}; + return { + ...options, + ...(config.headerLeft && routeParams.headerLeft !== undefined ? { headerLeft: routeParams.headerLeft } : {}), + ...(config.headerRight && routeParams.headerRight !== undefined ? { headerRight: routeParams.headerRight } : {}), + ...(config.headerBackVisible && routeParams.headerBackVisible !== undefined + ? { headerBackVisible: routeParams.headerBackVisible } + : {}), + ...(config.statusBarStyle && routeParams.statusBarStyle !== undefined ? { statusBarStyle: routeParams.statusBarStyle } : {}), + }; + }; + +const getCloseButtonPosition = ( + closeButtonPosition: CloseButtonPosition | undefined, + isFirstRouteInStack: boolean, + isModal: boolean, +): CloseButtonPosition => { + if (closeButtonPosition !== undefined) { + return closeButtonPosition; + } + if (isFirstRouteInStack && isModal) { + return CloseButtonPosition.Right; + } + return CloseButtonPosition.None; +}; + +const getHandleCloseAction = ( + onCloseButtonPressed: ((args: { navigation: any; route: any }) => void) | undefined, + navigation: any, + route: any, +) => { + if (onCloseButtonPressed) { + return () => onCloseButtonPressed({ navigation, route }); + } + return () => { + Keyboard.dismiss(); + navigation.goBack(null); + }; +}; const navigationStyle = ( { - closeButton = false, - closeButtonFunc, + closeButtonPosition, + closeButtonIfFirstInStack, + onCloseButtonPressed, ...opts - }: NavigationOptions & { - closeButton?: boolean; - - closeButtonFunc?: (deps: { navigation: any; route: any }) => React.ReactElement; + }: NativeStackNavigationOptions & { + closeButtonPosition?: CloseButtonPosition; + /** When set, show this close control only if this screen is the first route in the stack (e.g. Coin Control opened from wallet details). */ + closeButtonIfFirstInStack?: CloseButtonPosition; + onCloseButtonPressed?: (deps: { navigation: any; route: any }) => void; }, - formatter: OptionsFormatter, + formatter?: OptionsFormatter, ): NavigationOptionsGetter => { return theme => ({ navigation, route }) => { + const isFirstRouteInStack = navigation.getState().index === 0; + const isModal = route.params?.presentation === 'modal' || route.params?.presentation === 'transparentModal'; + const isFormSheet = route.params?.presentation === 'formSheet'; + + const closeButton = + closeButtonIfFirstInStack && isFirstRouteInStack + ? closeButtonIfFirstInStack + : getCloseButtonPosition(closeButtonPosition, isFirstRouteInStack, isModal); + const handleClose = getHandleCloseAction(onCloseButtonPressed, navigation, route); + + type HeaderItemsGetter = NonNullable; + let headerRight; - if (closeButton) { - const handleClose = closeButtonFunc - ? () => closeButtonFunc({ navigation, route }) - : () => { - Keyboard.dismiss(); - navigation.goBack(null); - }; - headerRight = () => ( - - - - ); - } + let headerLeft; + let unstable_headerRightItems: HeaderItemsGetter | undefined; + let unstable_headerLeftItems: HeaderItemsGetter | undefined; + + const renderCloseButtonElement = () => ( + + + + ); - let options: NavigationOptions = { - headerStyle: { - borderBottomWidth: 0, - elevation: 0, - shadowOpacity: 0, - shadowOffset: { height: 0, width: 0 }, + const buildUnstableCloseButtonItems = (): ReturnType => [ + { + type: 'button', + label: loc._.close, + icon: { type: 'sfSymbol', name: 'xmark' }, + identifier: 'NavigationCloseButton', + onPress: handleClose, + accessibilityLabel: loc._.close, }, + ]; + + if (closeButton === CloseButtonPosition.Right) { + headerRight = renderCloseButtonElement; + unstable_headerRightItems = buildUnstableCloseButtonItems; + } else if (closeButton === CloseButtonPosition.Left) { + headerLeft = renderCloseButtonElement; + unstable_headerLeftItems = buildUnstableCloseButtonItems; + } + const baseHeaderStyle = { + headerShadowVisible: false, headerTitleStyle: { - fontWeight: '600', + fontWeight: '600' as const, color: theme.colors.foregroundColor, }, - headerRight, - headerBackTitleVisible: false, headerTintColor: theme.colors.foregroundColor, - ...opts, + headerBackButtonDisplayMode: 'minimal', }; + const isLeftCloseButtonAndroid = closeButton === CloseButtonPosition.Left && Platform.OS === 'android'; - if (formatter) { - options = formatter(options, { theme, navigation, route }); - } - - return options; - }; -}; + const leftCloseButtonStyle = isLeftCloseButtonAndroid ? { headerBackImageSource: theme.closeImage } : { headerLeft }; -export default navigationStyle; + // statusBarStyle: auto is not supported on Android, so we get it based on the theme.barStyle + const statusBarStyle: NativeStackNavigationOptions['statusBarStyle'] = + opts.statusBarStyle && opts.statusBarStyle !== 'auto' ? opts.statusBarStyle : theme.barStyle === 'light-content' ? 'light' : 'dark'; -export const navigationStyleTx = (opts: NavigationOptions, formatter: OptionsFormatter): NavigationOptionsGetter => { - return theme => - ({ navigation, route }) => { - let options: NavigationOptions = { - headerStyle: { - borderBottomWidth: 0, - elevation: 0, - shadowOffset: { height: 0, width: 0 }, - }, - headerTitleStyle: { - fontWeight: '600', - color: theme.colors.foregroundColor, - }, - // headerBackTitle: null, - headerBackTitleVisible: false, - headerTintColor: theme.colors.foregroundColor, - headerLeft: () => ( - { - Keyboard.dismiss(); - navigation.goBack(null); - }} - > - - - ), + let options: NativeStackNavigationOptions = { + ...baseHeaderStyle, + ...leftCloseButtonStyle, + headerBackButtonDisplayMode: 'minimal', + headerRight, + ...(unstable_headerRightItems ? { unstable_headerRightItems } : {}), + ...(unstable_headerLeftItems ? { unstable_headerLeftItems } : {}), ...opts, + statusBarStyle, }; if (formatter) { @@ -135,3 +183,6 @@ export const navigationStyleTx = (opts: NavigationOptions, formatter: OptionsFor return options; }; }; + +export default navigationStyle; +export { CloseButtonPosition, withRouteParamHeaderOptions }; diff --git a/components/themes.ts b/components/themes.ts index 802d8b00210..f4250de811d 100644 --- a/components/themes.ts +++ b/components/themes.ts @@ -1,4 +1,4 @@ -import { DefaultTheme, DarkTheme, useTheme as useThemeBase } from '@react-navigation/native'; +import { DarkTheme, DefaultTheme, useTheme as useThemeBase } from '@react-navigation/native'; import { Appearance } from 'react-native'; export const BlueDefaultTheme = { @@ -8,12 +8,16 @@ export const BlueDefaultTheme = { scanImage: require('../img/scan.png'), colors: { ...DefaultTheme.colors, + borderWidth: 0.5, brandingColor: '#ffffff', customHeader: '#ffffff', foregroundColor: '#0c2550', borderTopColor: 'rgba(0, 0, 0, 0.1)', buttonBackgroundColor: '#ccddf9', + /** Softer fill for native iOS 26+ prominent header bar buttons (derived from `buttonBackgroundColor`). */ + headerProminentButtonBackgroundColor: 'rgba(204, 221, 249, 0.9)', buttonTextColor: '#0c2550', + secondButtonTextColor: '#50555C', buttonAlternativeTextColor: '#2f5fb3', buttonDisabledBackgroundColor: '#eef0f4', buttonDisabledTextColor: '#9aa0aa', @@ -22,12 +26,14 @@ export const BlueDefaultTheme = { alternativeTextColor: '#9aa0aa', alternativeTextColor2: '#0f5cc0', buttonBlueBackgroundColor: '#ccddf9', + buttonGrayBackgroundColor: '#EEEEEE', incomingBackgroundColor: '#d2f8d6', - incomingForegroundColor: '#37c0a1', + incomingForegroundColor: '#2FA380', outgoingBackgroundColor: '#f8d2d2', outgoingForegroundColor: '#d0021b', - successColor: '#37c0a1', + successColor: '#2FA380', failedColor: '#ff0000', + placeholderTextColor: '#81868e', shadowColor: '#000000', inverseForegroundColor: '#ffffff', hdborderColor: '#68BBE1', @@ -35,9 +41,9 @@ export const BlueDefaultTheme = { lnborderColor: '#FFB600', lnbackgroundColor: '#FFFAEF', background: '#FFFFFF', - lightButton: '#eef0f4', - ballReceive: '#d2f8d6', - ballOutgoing: '#f8d2d2', + lightButton: 'rgba(0, 0, 0, 0.05)', + ballReceive: 'rgba(31, 221, 26, 0.2)', + ballOutgoing: 'rgba(234, 51, 47, 0.2)', lightBorder: '#ededed', ballOutgoingExpired: '#EEF0F4', modal: '#ffffff', @@ -47,7 +53,7 @@ export const BlueDefaultTheme = { scanLabel: '#9AA0AA', feeText: '#81868e', feeLabel: '#d2f8d6', - feeValue: '#37c0a1', + feeValue: '#2FA380', feeActive: '#d2f8d6', labelText: '#81868e', cta2: '#062453', @@ -56,7 +62,7 @@ export const BlueDefaultTheme = { mainColor: '#CFDCF6', success: '#ccddf9', successCheck: '#0f5cc0', - msSuccessBG: '#37c0a1', + msSuccessBG: '#2FA380', msSuccessCheck: '#ffffff', newBlue: '#007AFF', redBG: '#F8D2D2', @@ -64,7 +70,19 @@ export const BlueDefaultTheme = { changeBackground: '#FDF2DA', changeText: '#F38C47', receiveBackground: '#D1F9D6', - receiveText: '#37C0A1', + receiveText: '#2FA380', + androidRippleColor: '#CCCCCC', + transactionPendingColor: '#2757C6', + transactionPendingBackgroundColor: '#DBEFFD', + transactionPendingIconBackground: 'rgba(0, 60, 240, 0.1)', + transactionPendingAnimationColor: '#2757C6', + transactionStateBumpButtonBackground: 'rgba(255, 255, 255, 0.4)', + transactionStateCancelButtonBackground: 'rgba(0, 0, 0, 0.05)', + transactionSentColor: '#BF2828', + transactionReceivedColor: '#2FA380', + cardSectionBackground: '#F9F9F9', + cardSectionHeaderBackground: '#F2F2F2', + cardBorderColor: 'rgba(0, 0, 0, 0.05)', }, }; @@ -81,16 +99,18 @@ export const BlueDarkTheme: Theme = { customHeader: '#000000', brandingColor: '#000000', borderTopColor: '#9aa0aa', + background: '#000000', foregroundColor: '#ffffff', buttonDisabledBackgroundColor: '#3A3A3C', buttonBackgroundColor: '#3A3A3C', + headerProminentButtonBackgroundColor: 'rgba(58, 58, 60, 0.6)', buttonTextColor: '#ffffff', lightButton: 'rgba(255,255,255,.1)', buttonAlternativeTextColor: '#ffffff', alternativeTextColor: '#9aa0aa', alternativeTextColor2: '#0A84FF', - ballReceive: '#202020', - ballOutgoing: '#202020', + ballReceive: 'rgba(31, 221, 26, 0.2)', + ballOutgoing: 'rgba(234, 51, 47, 0.2)', lightBorder: '#313030', ballOutgoingExpired: '#202020', modal: '#202020', @@ -119,13 +139,38 @@ export const BlueDarkTheme: Theme = { changeBackground: '#5A4E4E', changeText: '#F38C47', receiveBackground: 'rgba(210,248,214,.2)', - receiveText: '#37C0A1', + receiveText: '#2FA380', + outgoingBackgroundColor: 'rgba(187, 6, 6, 0.2)', + outgoingForegroundColor: '#FC6D6D', + incomingBackgroundColor: 'rgba(5, 159, 54, 0.3)', + incomingForegroundColor: '#2FA380', + transactionPendingIconBackground: 'rgba(90, 158, 255, 0.3)', + transactionPendingAnimationColor: '#5A9EFF', + androidRippleColor: '#444444', + transactionPendingColor: '#5A9EFF', + transactionPendingBackgroundColor: 'rgba(10, 132, 255, 0.15)', + transactionStateBumpButtonBackground: 'rgba(255, 255, 255, 0.15)', + transactionStateCancelButtonBackground: 'rgba(255, 255, 255, 0.08)', + transactionSentColor: '#FC6D6D', + transactionReceivedColor: '#2FA380', + cardSectionBackground: '#1C1C1E', + cardSectionHeaderBackground: '#2C2C2E', + cardBorderColor: 'rgba(255, 255, 255, 0.08)', }, }; // Casting theme value to get autocompletion export const useTheme = (): Theme => useThemeBase() as Theme; +export const platformColors = { + background: BlueDefaultTheme.colors.background, + card: BlueDefaultTheme.colors.modal ?? BlueDefaultTheme.colors.elevated ?? BlueDefaultTheme.colors.background, + text: BlueDefaultTheme.colors.foregroundColor, + secondaryText: BlueDefaultTheme.colors.alternativeTextColor ?? BlueDefaultTheme.colors.darkGray, + separator: BlueDefaultTheme.colors.lightBorder ?? BlueDefaultTheme.colors.borderTopColor, + chevron: BlueDefaultTheme.colors.alternativeTextColor ?? BlueDefaultTheme.colors.darkGray, +}; + export class BlueCurrentTheme { static colors: Theme['colors']; static closeImage: Theme['closeImage']; @@ -136,6 +181,13 @@ export class BlueCurrentTheme { BlueCurrentTheme.colors = isColorSchemeDark ? BlueDarkTheme.colors : BlueDefaultTheme.colors; BlueCurrentTheme.closeImage = isColorSchemeDark ? BlueDarkTheme.closeImage : BlueDefaultTheme.closeImage; BlueCurrentTheme.scanImage = isColorSchemeDark ? BlueDarkTheme.scanImage : BlueDefaultTheme.scanImage; + const colors = BlueCurrentTheme.colors; + platformColors.background = colors.background; + platformColors.card = colors.modal ?? colors.elevated ?? colors.background; + platformColors.text = colors.foregroundColor; + platformColors.secondaryText = colors.alternativeTextColor ?? colors.darkGray; + platformColors.separator = colors.lightBorder ?? colors.borderTopColor; + platformColors.chevron = colors.alternativeTextColor ?? colors.darkGray; } } diff --git a/components/types.ts b/components/types.ts new file mode 100644 index 00000000000..3544355cd82 --- /dev/null +++ b/components/types.ts @@ -0,0 +1,57 @@ +import { AccessibilityRole, ViewStyle, ColorValue, GestureResponderEvent } from 'react-native'; + +export interface Action { + id: string | number; + text: string; + icon?: { + iconValue: string; + }; + menuTitle?: string; + subtitle?: string; + menuState?: 'mixed' | boolean | undefined; + displayInline?: boolean; // Indicates if subactions should be displayed inline or nested (iOS only) + image?: string; + imageColor?: ColorValue; + destructive?: boolean; + hidden?: boolean; + disabled?: boolean; + subactions?: Action[]; // Nested/Inline actions (subactions) within an action +} + +export interface ToolTipMenuProps { + actions: Action[] | Action[][]; + children: React.ReactNode; + enableAndroidRipple?: boolean; + onPressMenuItem: (id: string) => void; + title?: string; + // When true (default) the menu opens on long-press; when false it opens + // on a single tap (dropdown mode). + shouldOpenOnLongPress?: boolean; + // Hint that the trigger should be styled like a button (e.g. center it). + isButton?: boolean; + // Optional short-tap action. When provided, the trigger is wrapped in a + // Pressable so that a quick tap fires this callback while a long-press + // (or single tap in dropdown mode) still opens the native menu. + onPress?: (event: GestureResponderEvent) => void; + accessibilityRole?: AccessibilityRole; + disabled?: boolean; + testID?: string; + style?: ViewStyle | ViewStyle[]; + accessibilityLabel?: string; + accessibilityHint?: string; + accessibilityState?: object; + buttonStyle?: ViewStyle | ViewStyle[]; +} + +export enum HandOffActivityType { + ReceiveOnchain = 'io.bluewallet.bluewallet.receiveonchain', + Xpub = 'io.bluewallet.bluewallet.xpub', + ViewInBlockExplorer = 'io.bluewallet.bluewallet.blockexplorer', +} + +export interface HandOffComponentProps { + url?: string; + title?: string; + type: HandOffActivityType; + userInfo?: object; +} diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 00000000000..18e61f786d0 --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,3 @@ +app_identifier("io.bluewallet.bluewallet") +apple_id(ENV["APPLE_ID"]) # Your Apple email ID +itc_team_id(ENV["ITC_TEAM_ID"]) # App Store Connect Team ID \ No newline at end of file diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 00000000000..0ab6cf118a0 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,1746 @@ +# Define app identifiers once for reuse across lanes +def app_identifiers + [ + "io.bluewallet.bluewallet", + "io.bluewallet.bluewallet.Stickers", + "io.bluewallet.bluewallet.MarketWidget" + ] +end + +def app_store_state_readable(state) + states = { + "DEVELOPER_REJECTED" => "Developer Rejected", + "PREPARE_FOR_SUBMISSION" => "Prepare for Submission", + "WAITING_FOR_REVIEW" => "Waiting for Review", + "IN_REVIEW" => "In Review", + "PENDING_DEVELOPER_RELEASE" => "Pending Developer Release", + "READY_FOR_SALE" => "Ready for Sale", + "REJECTED" => "Rejected", + "METADATA_REJECTED" => "Metadata Rejected" + } + + states[state] || state +end + +require 'securerandom' + +default_platform(:android) +PROJECT_ROOT = File.expand_path("..", __dir__) +project_root = PROJECT_ROOT + +module AndroidHelpers + module_function + + def require_env!(keys) + missing = Array(keys).select { |key| ENV[key].nil? || ENV[key].empty? } + UI.user_error!("Missing required env vars: #{missing.join(', ')}") unless missing.empty? + end + + def project_root + PROJECT_ROOT + end + + def keystore_paths + { + hex: File.join(project_root, 'bluewallet-release-key.keystore.hex'), + file: File.join(project_root, 'android', 'bluewallet-release-key.keystore'), + } + end + + def write_keystore_from_hex!(hex_value, paths) + UI.user_error!("KEYSTORE_FILE_HEX environment variable is missing") if hex_value.nil? || hex_value.empty? + File.write(paths[:hex], hex_value) + Actions.sh("xxd -plain -revert #{paths[:hex]} > #{paths[:file]}") do |status| + UI.user_error!("Error reverting hex to keystore") unless status.success? + end + File.delete(paths[:hex]) + end + + def version_name_and_update_code!(build_gradle_path, build_number) + gradle_contents = File.read(build_gradle_path) + File.write(build_gradle_path, gradle_contents.gsub(/versionCode\s+\d+/, "versionCode #{build_number}")) + version_name = File.read(build_gradle_path)[/versionName\s+"([^"]+)"/, 1] + UI.user_error!("Failed to extract versionName from #{build_gradle_path}") if version_name.nil? || version_name.empty? + version_name + end + + def branch_name + raw = ENV['GITHUB_HEAD_REF'] || ENV['GITHUB_REF_NAME'] || `git rev-parse --abbrev-ref HEAD`.strip + sanitized = raw.to_s.gsub(/[^a-zA-Z0-9_-]/, '_') + sanitized.empty? ? 'master' : sanitized + end + + def apk_name(version_name, build_number, branch) + branch != 'master' ? "BlueWallet-#{version_name}-#{build_number}-#{branch}.apk" : "BlueWallet-#{version_name}-#{build_number}.apk" + end + + def apksigner_path + sdk_root = ENV['ANDROID_HOME'] || ENV['ANDROID_SDK_ROOT'] + Dir.glob(File.join(sdk_root.to_s, 'build-tools', '*', 'apksigner')).sort.last + end + + def gradle_log_path + path = File.join(project_root, 'fastlane', 'logs', 'gradle-build.log') + FileUtils.mkdir_p(File.dirname(path)) + path + end + + def assemble_release!(log_path:) + Actions.sh("cd android && ./gradlew assembleRelease --no-daemon --stacktrace --console=plain | tee #{log_path}") do |status| + UI.user_error!("Gradle assembleRelease failed") unless status.success? + end + end + + def resolve_apk_paths(version_name:, build_number:, branch_name:) + apk_dir = File.join(project_root, 'android', 'app', 'build', 'outputs', 'apk', 'release') + unsigned = File.join(apk_dir, 'app-release-unsigned.apk') + fallback = File.join(apk_dir, 'app-release.apk') + signed = File.join(apk_dir, apk_name(version_name, build_number, branch_name)) + { unsigned: unsigned, fallback: fallback, signed: signed } + end + + def finalize_apk!(paths) + candidate = File.exist?(paths[:unsigned]) ? paths[:unsigned] : paths[:fallback] + UI.user_error!("Unsigned APK not found at path: #{paths[:unsigned]} or #{paths[:fallback]}") unless File.exist?(candidate) + FileUtils.mv(candidate, paths[:signed]) + paths[:signed] + end + + def sign_apk!(apk_path, keystore_path, keystore_password) + signer = apksigner_path + UI.user_error!("apksigner not found in Android build-tools") if signer.nil? || signer.empty? + Actions.sh("#{signer} sign --ks #{keystore_path} --ks-pass=pass:#{keystore_password} #{apk_path}") + end + + def write_github_output(hash) + return unless ENV['GITHUB_OUTPUT'] && !ENV['GITHUB_OUTPUT'].empty? + + File.open(ENV['GITHUB_OUTPUT'], 'a') do |f| + hash.each { |key, value| f.puts("#{key}=#{value}") } + end + end + + def ensure_temp_credentials!(auto: false) + return unless auto + + temp_dir = Dir.mktmpdir('bw-temp-keystore') + keystore_path = File.join(temp_dir, 'temp.keystore') + password = "temp#{SecureRandom.hex(6)}" + alias_name = "bluewallet-temp" + + Actions.sh( + "keytool -genkeypair -v -keystore #{keystore_path} -storepass #{password} -keypass #{password} " \ + "-alias #{alias_name} -keyalg RSA -keysize 2048 -validity 10000 " \ + "-dname \"CN=Temp,O=BlueWallet,OU=CI,L=NY,ST=NY,C=US\"" + ) do |status| + UI.user_error!("Failed to create temporary keystore with keytool") unless status.success? + end + + keystore_hex = File.binread(keystore_path).unpack1('H*') + ENV['KEYSTORE_FILE_HEX'] = keystore_hex + ENV['KEYSTORE_PASSWORD'] = password + end +end + +# Add session caching for App Store Connect +def cached_app_store_connect_login + # Skip if already authenticated and token is not expired + if defined?(Spaceship::ConnectAPI) && Spaceship::ConnectAPI.token && !Spaceship::ConnectAPI.token.expired? + UI.message("Using existing App Store Connect session") + return true + end + + UI.message("Logging in to App Store Connect...") + + # Try API key authentication first + api_key_path = ENV["APPLE_API_KEY_PATH"] || "./appstore_api_key.json" + + if File.exist?(api_key_path) && ENV["APPLE_API_KEY_ID"] && ENV["APPLE_API_ISSUER_ID"] + UI.message("Using API key authentication for App Store Connect") + api_key = app_store_connect_api_key( + key_id: ENV["APPLE_API_KEY_ID"], + issuer_id: ENV["APPLE_API_ISSUER_ID"], + key_filepath: api_key_path, + duration: 1200, # 20 minute session + in_house: false + ) + + # Store the API key in lane context for reuse + ENV["SPACESHIP_CONNECT_API_KEY"] = api_key + + # Force App Store Connect API to use this key + Spaceship::ConnectAPI.token = api_key + + UI.success("Successfully authenticated with App Store Connect API Key") + return true + elsif ENV["FASTLANE_USER"] && ENV["FASTLANE_PASSWORD"] + UI.message("Using username/password from environment variables") + # Use credentials from environment variables + Spaceship::ConnectAPI.login( + use_portal: true, + use_tunes: true, + portal_team_id: ENV["TEAM_ID"], + tunes_team_id: ENV["ITC_TEAM_ID"] + ) + UI.success("Successfully authenticated with Apple ID") + return true + else + UI.message("Using interactive username/password authentication") + # Last resort - interactive login + Spaceship::ConnectAPI.login( + use_portal: true, + use_tunes: true + ) + UI.success("Successfully authenticated with Apple ID") + return true + end +end + +before_all do |lane, options| + if ENV['SKIP_APP_STORE_CONNECT_AUTH'] == '1' + UI.message('Skipping App Store Connect authentication (SKIP_APP_STORE_CONNECT_AUTH=1)') + next + end + + skip_auth_lanes = ['register_devices_from_txt', 'build_catalyst_app_lane', 'install_pods', 'clear_derived_data_lane'] + lane_name = lane.to_s + should_skip_auth = skip_auth_lanes.any? { |skip_lane| lane_name == skip_lane || lane_name.end_with?(" #{skip_lane}") } + + # Check if we need App Store Connect for this lane + unless should_skip_auth + begin + # Try to authenticate once at the beginning + require 'spaceship' + cached_app_store_connect_login + rescue => ex + UI.error("Authentication failed: #{ex.message}") + # Continue anyway as some lanes might not need authentication + end + end +end + +# =========================== +# Android Lanes +# =========================== + +platform :android do + + desc "Prepare the keystore file" + lane :prepare_keystore do + Dir.chdir(PROJECT_ROOT) do + paths = AndroidHelpers.keystore_paths + AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], paths) + UI.message("Keystore created successfully.") + end + end + + desc "Update version, build number, and sign APK" + lane :update_version_build_and_sign_apk do |options| + Dir.chdir(PROJECT_ROOT) do + AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials]) + AndroidHelpers.require_env!(%w[BUILD_NUMBER KEYSTORE_PASSWORD KEYSTORE_FILE_HEX]) + + keystore_paths = AndroidHelpers.keystore_paths + AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths) + + build_gradle_path = File.join('android', 'app', 'build.gradle') + version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, ENV['BUILD_NUMBER']) + branch = AndroidHelpers.branch_name + apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: ENV['BUILD_NUMBER'], branch_name: branch) + + UI.message("Building APK...") + AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path) + UI.message("APK build completed.") + + signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths) + ENV['APK_OUTPUT_PATH'] = File.expand_path(signed_apk_path) + + UI.message("Signing APK with apksigner...") + AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD']) + UI.message("APK signed successfully: #{signed_apk_path}") + + FileUtils.rm_f(keystore_paths[:file]) + end + end + + desc "Build and sign release APK" + lane :build_release_apk do |options| + Dir.chdir(PROJECT_ROOT) do + # Allow caller to pass a build_number; otherwise fall back to env or timestamp + build_number = options[:build_number] || ENV['BUILD_NUMBER'] || Time.now.to_i.to_s + ENV['BUILD_NUMBER'] = build_number + + AndroidHelpers.ensure_temp_credentials!(auto: options[:auto_credentials]) + AndroidHelpers.require_env!(%w[KEYSTORE_FILE_HEX KEYSTORE_PASSWORD]) + + keystore_paths = AndroidHelpers.keystore_paths + AndroidHelpers.write_keystore_from_hex!(ENV['KEYSTORE_FILE_HEX'], keystore_paths) + + build_gradle_path = File.join('android', 'app', 'build.gradle') + version_name = AndroidHelpers.version_name_and_update_code!(build_gradle_path, build_number) + branch = AndroidHelpers.branch_name + + UI.message("Building release APK...") + AndroidHelpers.assemble_release!(log_path: AndroidHelpers.gradle_log_path) + + apk_paths = AndroidHelpers.resolve_apk_paths(version_name: version_name, build_number: build_number, branch_name: branch) + signed_apk_path = AndroidHelpers.finalize_apk!(apk_paths) + + UI.message("Signing APK with apksigner...") + AndroidHelpers.sign_apk!(signed_apk_path, keystore_paths[:file], ENV['KEYSTORE_PASSWORD']) + UI.success("APK signed successfully: #{signed_apk_path}") + + FileUtils.rm_f(keystore_paths[:file]) + + apk_absolute_path = File.expand_path(signed_apk_path) + ENV['APK_OUTPUT_PATH'] = apk_absolute_path + ENV['APK_VERSION_NAME'] = version_name + ENV['APK_VERSION_CODE'] = build_number + + AndroidHelpers.write_github_output( + apk_output_path: apk_absolute_path, + apk_version_name: version_name, + apk_version_code: build_number, + ) + end + end + + desc "Upload APK to BrowserStack and post result as PR comment" + lane :upload_to_browserstack_and_comment do + Dir.chdir(PROJECT_ROOT) do + apk_path = ENV['APK_PATH'] + if apk_path.nil? || apk_path.empty? + UI.message("No APK path provided, searching for APK...") + apk_path = `find ./ -name "*.apk"`.strip + UI.user_error!("No APK file found") if apk_path.nil? || apk_path.empty? + end + + UI.message("Uploading APK to BrowserStack: #{apk_path}...") + upload_to_browserstack_app_live( + file_path: apk_path, + browserstack_username: ENV['BROWSERSTACK_USERNAME'], + browserstack_access_key: ENV['BROWSERSTACK_ACCESS_KEY'] + ) + + app_url = ENV['BROWSERSTACK_LIVE_APP_ID'] + UI.user_error!("BrowserStack upload failed, no app URL returned") if app_url.nil? || app_url.empty? + + apk_filename = File.basename(apk_path) + apk_download_url = ENV['APK_OUTPUT_PATH'] + browserstack_hashed_id = app_url.gsub('bs://', '') + pr_number = ENV['GITHUB_PR_NUMBER'] + + comment_identifier = '### APK Successfully Uploaded to BrowserStack' + + comment = <<~COMMENT + #{comment_identifier} + + You can test it on the following devices: + + - [Google Pixel 9 (Android 15)](https://app-live.browserstack.com/dashboard#os=android&os_version=15.0&device=Google+Pixel+8&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Google Pixel 8 (Android 14)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Google+Pixel+8&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Google Pixel 7 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Google+Pixel+7&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Google Pixel 5 (Android 12)](https://app-live.browserstack.com/dashboard#os=android&os_version=12.0&device=Google+Pixel+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Google Pixel 3a (Android 9)](https://app-live.browserstack.com/dashboard#os=android&os_version=9.0&device=Google+Pixel+3a&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + + - [Samsung Galaxy Z Fold 6 (Android 14)](https://app-live.browserstack.com/dashboard#os=android&os_version=14.0&device=Samsung+Galaxy+Z+Fold+6&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Samsung Galaxy Z Fold 5 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Z+Fold+5&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Samsung Galaxy Tab S9 (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=Samsung+Galaxy+Tab+S9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + - [Samsung Galaxy Note 9 (Android 8.1)](https://app-live.browserstack.com/dashboard#os=android&os_version=8.1&device=Samsung+Galaxy+Note+9&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + + - [OnePlus 11R (Android 13)](https://app-live.browserstack.com/dashboard#os=android&os_version=13.0&device=OnePlus+11R&app_hashed_id=#{browserstack_hashed_id}&scale_to_fit=true&speed=1&start=true&browser=chrome) + **Filename**: [#{apk_filename}](#{apk_download_url}) + **BrowserStack App URL**: #{app_url} + COMMENT + + if pr_number + begin + repo = ENV['GITHUB_REPOSITORY'] + repo_owner, repo_name = repo.split('/') + + UI.message("Fetching existing comments for PR ##{pr_number}...") + + comments_json = `gh api -X GET /repos/#{repo_owner}/#{repo_name}/issues/#{pr_number}/comments` + comments = JSON.parse(comments_json) + + comments.each do |comment| + if comment['body'].start_with?(comment_identifier) + comment_id = comment['id'] + UI.message("Deleting previous comment ID: #{comment_id}...") + `gh api -X DELETE /repos/#{repo_owner}/#{repo_name}/issues/comments/#{comment_id}` + UI.success("Deleted comment ID: #{comment_id}") + end + end + + rescue => e + UI.error("Failed to delete previous comments: #{e.message}") + end + else + UI.important("No PR number found. Skipping deletion of previous comments.") + end + + if pr_number + begin + escaped_comment = comment.gsub("'", "'\\''") + sh("GH_TOKEN=#{ENV['GH_TOKEN']} gh pr comment #{pr_number} --body '#{escaped_comment}'") + UI.success("Posted new comment to PR ##{pr_number}") + rescue => e + UI.error("Failed to post comment to PR: #{e.message}") + end + else + UI.important("No PR number found. Skipping PR comment.") + end + end + end +end + + +# =========================== +# iOS Lanes +# =========================== + +platform :ios do + # Add helper methods for error handling and retries + def ensure_env_vars(vars) + vars.each do |var| + UI.user_error!("#{var} environment variable is missing") if ENV[var].nil? || ENV[var].empty? + end + end + + def log_success(message) + UI.success("✅ #{message}") + end + + def log_error(message) + UI.error("❌ #{message}") + end + + # Method to safely call actions with retry logic + def with_retry(max_attempts = 3, action_name = "") + attempts = 0 + begin + attempts += 1 + yield + rescue => e + if attempts < max_attempts + wait_time = 10 * attempts + log_error("Attempt #{attempts}/#{max_attempts} for #{action_name} failed: #{e.message}") + UI.message("Retrying in #{wait_time} seconds...") + sleep(wait_time) + retry + else + log_error("#{action_name} failed after #{max_attempts} attempts: #{e.message}") + raise e + end + end + end + + desc "Register new devices from a file" + lane :register_devices_from_txt do + UI.message("Registering new devices from file...") + + csv_path = "../../devices.txt" # Update this with the actual path to your file + + # Register devices using the devices_file parameter + register_devices( + devices_file: csv_path + ) + + UI.message("Devices registered successfully.") + + # Update provisioning profiles for all app identifiers + app_identifiers.each do |app_identifier| + match( + type: "development", + app_identifier: app_identifier, + readonly: false, # Regenerate provisioning profile if needed + force_for_new_devices: true, + clone_branch_directly: true + ) + end + + UI.message("Development provisioning profiles updated.") + end + + desc "Create a temporary keychain" + lane :create_temp_keychain do + UI.message("Creating a temporary keychain...") + + create_keychain( + name: "temp_keychain", + password: ENV["KEYCHAIN_PASSWORD"], + default_keychain: true, + unlock: true, + timeout: 3600, + lock_when_sleeps: true + ) + + UI.message("Temporary keychain created successfully.") + end + + desc "Synchronize certificates and provisioning profiles" + lane :setup_provisioning_profiles do + required_vars = ["GIT_ACCESS_TOKEN", "GIT_URL", "ITC_TEAM_ID", "ITC_TEAM_NAME", "KEYCHAIN_PASSWORD"] + ensure_env_vars(required_vars) + + UI.message("Setting up provisioning profiles...") + + # Iterate over app identifiers to fetch provisioning profiles + app_identifiers.each do |app_identifier| + with_retry(3, "Fetching provisioning profile for #{app_identifier}") do + UI.message("Fetching provisioning profile for #{app_identifier}...") + match( + git_basic_authorization: ENV["GIT_ACCESS_TOKEN"], + git_url: ENV["GIT_URL"], + type: "appstore", + clone_branch_directly: true, + platform: "ios", + app_identifier: app_identifier, + team_id: ENV["ITC_TEAM_ID"], + team_name: ENV["ITC_TEAM_NAME"], + readonly: true, + keychain_name: "temp_keychain", + keychain_password: ENV["KEYCHAIN_PASSWORD"] + ) + log_success("Successfully fetched provisioning profile for #{app_identifier}") + end + end + + log_success("All provisioning profiles set up") + end + + # Only these targets support Mac Catalyst (watch/stickers do not) + def catalyst_app_identifiers + [ + "io.bluewallet.bluewallet", + "io.bluewallet.bluewallet.MarketWidget" + ] + end + + desc "Fetch development certificates and provisioning profiles for Mac Catalyst" + lane :fetch_dev_profiles_catalyst do + match( + type: "development", + platform: "catalyst", + app_identifier: catalyst_app_identifiers, + readonly: true, + clone_branch_directly: true + ) + end + + desc "Fetch App Store certificates and provisioning profiles for Mac Catalyst" + lane :fetch_appstore_profiles_catalyst do + match( + type: "appstore", + platform: "catalyst", + app_identifier: catalyst_app_identifiers, + readonly: true, + clone_branch_directly: true + ) + end + + desc "Create provisioning profiles for Mac Catalyst (first-time setup)" + lane :setup_catalyst_provisioning_profiles do + catalyst_app_identifiers.each do |app_identifier| + match( + type: "development", + platform: "catalyst", + app_identifier: app_identifier, + readonly: false, + force_for_new_devices: true, + clone_branch_directly: true + ) + + match( + type: "appstore", + platform: "catalyst", + app_identifier: app_identifier, + readonly: false, + clone_branch_directly: true + ) + end + end + + desc "Clear derived data" + lane :clear_derived_data_lane do + UI.message("Clearing derived data...") + clear_derived_data + end + + desc "Increment build number" + lane :increment_build_number_lane do + UI.message("Incrementing build number to current timestamp...") + + # Set the new build number + increment_build_number( + xcodeproj: "ios/BlueWallet.xcodeproj", + build_number: ENV["NEW_BUILD_NUMBER"] + ) + + UI.message("Build number set to: #{ENV['NEW_BUILD_NUMBER']}") + end + + desc "Install CocoaPods dependencies" + lane :install_pods do + UI.message("Installing CocoaPods dependencies...") + cocoapods(podfile: "ios/Podfile", + try_repo_update_on_error: true, + repo_update: true, + + clean_install: true) + end + + desc "Build Mac Catalyst app, handle code signing, create DMG with multilingual README, and set GitHub outputs" + lane :build_catalyst_app_lane do + Dir.chdir(project_root) do + UI.message("Building Mac Catalyst application from: #{Dir.pwd}") + + workspace_path = File.join(project_root, "ios", "BlueWallet.xcworkspace") + derived_data_path = File.join(project_root, "ios", "build", "catalyst-derived-data") + output_dir = File.join(project_root, "ios", "build", "catalyst-output") + + if ENV['SKIP_CLEAR_DERIVED_DATA'] == '1' + UI.message('Skipping clear_derived_data_lane (SKIP_CLEAR_DERIVED_DATA=1)') + else + clear_derived_data_lane + end + FileUtils.mkdir_p(derived_data_path) + FileUtils.mkdir_p(output_dir) + + # Only these targets support Mac Catalyst + catalyst_identifiers = [ + "io.bluewallet.bluewallet", + "io.bluewallet.bluewallet.MarketWidget" + ] + + has_signing_creds = ENV['CATALYST_SIGNING_IDENTITY'] && !ENV['CATALYST_SIGNING_IDENTITY'].empty? && + ENV['CATALYST_TEAM_ID'] && !ENV['CATALYST_TEAM_ID'].empty? + has_match_creds = ENV['GIT_URL'] && !ENV['GIT_URL'].empty? && + ENV['GIT_ACCESS_TOKEN'] && !ENV['GIT_ACCESS_TOKEN'].empty? + should_sign = has_signing_creds && has_match_creds && ENV['CATALYST_SKIP_CODESIGNING'] != '1' + signing_identity = ENV['CATALYST_SIGNING_IDENTITY'] || "Apple Distribution" + + xcargs_str = "ARCHS=arm64 ONLY_ACTIVE_ARCH=YES" + if should_sign + UI.message("Setting up Mac Catalyst provisioning profiles via match...") + team_id = ENV['CATALYST_TEAM_ID'] + match_readonly = ENV['MATCH_READONLY'] != 'false' + + # Create/fetch provisioning profiles for catalyst targets + catalyst_identifiers.each do |app_id| + match( + type: "appstore", + platform: "catalyst", + app_identifier: app_id, + team_id: team_id, + git_url: ENV['GIT_URL'], + git_basic_authorization: ENV['GIT_ACCESS_TOKEN'], + readonly: match_readonly, + clone_branch_directly: true, + keychain_name: ENV['KEYCHAIN_NAME'] || "login", + keychain_password: ENV['KEYCHAIN_PASSWORD'] || "" + ) + end + + xcargs_str += " DEVELOPMENT_TEAM=#{team_id}" + xcargs_str += " CODE_SIGN_IDENTITY=\"#{signing_identity}\"" + xcargs_str += " CODE_SIGN_STYLE=Manual" + + # Set provisioning profile specifiers per catalyst target + catalyst_identifiers.each do |app_id| + profile_name = "match AppStore #{app_id} catalyst" + # Convert bundle ID to xcodebuild target setting key + # e.g., io.bluewallet.bluewallet -> PROVISIONING_PROFILE_SPECIFIER for that target + xcargs_str += " PROVISIONING_PROFILE_SPECIFIER_#{app_id.gsub('.', '_')}=\"#{profile_name}\"" + end + + UI.success("Provisioning profiles configured for Mac Catalyst") + else + # Disable code signing entirely so xcodebuild doesn't look for provisioning profiles + xcargs_str += " CODE_SIGN_IDENTITY=- CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO" + UI.message("No signing credentials provided — building without code signing") + end + + build_app( + scheme: "BlueWallet", + workspace: workspace_path, + configuration: "Release", + destination: "generic/platform=macOS,variant=Mac Catalyst", + xcargs: xcargs_str, + clean: true, + skip_codesigning: !should_sign, + skip_package_ipa: true, + derived_data_path: derived_data_path, + buildlog_path: File.join(project_root, "ios", "build_logs") + ) + + archive_path = lane_context[SharedValues::XCODEBUILD_ARCHIVE] + if archive_path.nil? || archive_path.empty? + archive_path = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive")).max_by { |path| File.mtime(path) } + end + + candidate_paths = [] + candidate_paths << File.join(archive_path, "Products/Applications/BlueWallet.app") if archive_path + candidate_paths << Dir.glob(File.join(derived_data_path, "Build/Products/Release-maccatalyst/*.app")).first + candidate_paths << Dir.glob(File.join(derived_data_path, "Build/Intermediates.noindex/ArchiveIntermediates/BlueWallet/BuildProductsPath/Release-maccatalyst/*.app")).first + + catalyst_app_path = candidate_paths.compact.find { |path| File.exist?(path) } + UI.user_error!("Mac Catalyst app was not found after build") if catalyst_app_path.nil? + UI.success("Mac Catalyst app found at: #{catalyst_app_path}") + + if should_sign && ENV['CATALYST_SIGNING_IDENTITY'] && !ENV['CATALYST_SIGNING_IDENTITY'].empty? + UI.message("Re-signing app with: #{signing_identity}") + sh("codesign", + "--deep", + "--force", + "--options", "runtime", + "--sign", signing_identity, + catalyst_app_path) + sh("codesign", + "--verify", + "--deep", + "--strict", + catalyst_app_path) + UI.success("App signed and verified") + else + # Ad-hoc sign so macOS doesn't treat the app as "damaged" + UI.message("Ad-hoc signing app to avoid Gatekeeper quarantine issues...") + sh("codesign", + "--deep", + "--force", + "--sign", "-", + catalyst_app_path) + UI.success("App ad-hoc signed") + end + + dmg_path = File.join(output_dir, "BlueWallet-Mac-Catalyst.dmg") + UI.message("Creating DMG at: #{dmg_path}") + + dmg_staging = File.join(output_dir, "dmg-staging") + FileUtils.rm_rf(dmg_staging) + FileUtils.mkdir_p(dmg_staging) + FileUtils.cp_r(catalyst_app_path, dmg_staging) + + sh("ln", "-s", "/Applications", "#{dmg_staging}/Applications") + + # Add README with first-launch instructions + readme_path = File.join(dmg_staging, "README - Read Before Opening.txt") + File.write(readme_path, <<~README) + ╔══════════════════════════════════════════════════════════════╗ + ║ BlueWallet for macOS ║ + ╚══════════════════════════════════════════════════════════════╝ + + ═══ ENGLISH ═══════════════════════════════════════════════════ + + INSTALLATION: Drag "BlueWallet.app" into "Applications". + + FIRST LAUNCH — macOS may show a warning. To open: + • Right-click the app → Open → click "Open" in the dialog. + • Or: System Settings → Privacy & Security → Open Anyway. + • Or (Terminal): xattr -cr /Applications/BlueWallet.app + You only need to do this once. + + ═══ ESPAÑOL ════════════════════════════════════════════════════ + + INSTALACIÓN: Arrastra "BlueWallet.app" a "Aplicaciones". + + PRIMER INICIO — macOS puede mostrar una advertencia. Para abrir: + • Haz clic derecho en la app → Abrir → clic en "Abrir". + • O: Ajustes del Sistema → Privacidad y Seguridad → Abrir igualmente. + • O (Terminal): xattr -cr /Applications/BlueWallet.app + Solo necesitas hacerlo una vez. + + ═══ 中文 ═══════════════════════════════════════════════════════ + + 安装:将 "BlueWallet.app" 拖入 "应用程序" 文件夹。 + + 首次启动 — macOS 可能会显示警告。打开方法: + • 右键点击应用 → 打开 → 在对话框中点击"打开"。 + • 或:系统设置 → 隐私与安全性 → 仍要打开。 + • 或(终端):xattr -cr /Applications/BlueWallet.app + 只需操作一次。 + + ═══ PORTUGUÊS ══════════════════════════════════════════════════ + + INSTALAÇÃO: Arraste "BlueWallet.app" para "Aplicativos". + + PRIMEIRA ABERTURA — o macOS pode exibir um aviso. Para abrir: + • Clique com o botão direito → Abrir → clique em "Abrir". + • Ou: Ajustes do Sistema → Privacidade e Segurança → Abrir Mesmo Assim. + • Ou (Terminal): xattr -cr /Applications/BlueWallet.app + Você só precisa fazer isso uma vez. + + ═══ РУССКИЙ ════════════════════════════════════════════════════ + + УСТАНОВКА: Перетащите "BlueWallet.app" в "Программы". + + ПЕРВЫЙ ЗАПУСК — macOS может показать предупреждение. Чтобы открыть: + • Нажмите правой кнопкой → Открыть → нажмите «Открыть». + • Или: Системные настройки → Конфиденциальность → Подтвердить открытие. + • Или (Терминал): xattr -cr /Applications/BlueWallet.app + Это нужно сделать только один раз. + + ═══ 日本語 ═════════════════════════════════════════════════════ + + インストール:「BlueWallet.app」を「アプリケーション」にドラッグ。 + + 初回起動 — macOS が警告を表示する場合があります。開くには: + • アプリを右クリック →「開く」→ ダイアログで「開く」をクリック。 + • または:システム設定 → プライバシーとセキュリティ →「このまま開く」。 + • または(ターミナル):xattr -cr /Applications/BlueWallet.app + この操作は一度だけ必要です。 + + ═══ DEUTSCH ════════════════════════════════════════════════════ + + INSTALLATION: Ziehe „BlueWallet.app" in den Ordner „Programme". + + ERSTER START — macOS zeigt möglicherweise eine Warnung. Zum Öffnen: + • Rechtsklick auf die App → Öffnen → „Öffnen" klicken. + • Oder: Systemeinstellungen → Datenschutz & Sicherheit → Dennoch öffnen. + • Oder (Terminal): xattr -cr /Applications/BlueWallet.app + Dies ist nur beim ersten Mal nötig. + + ═══ FRANÇAIS ═══════════════════════════════════════════════════ + + INSTALLATION : Glissez « BlueWallet.app » dans « Applications ». + + PREMIER LANCEMENT — macOS peut afficher un avertissement. Pour ouvrir : + • Clic droit sur l'app → Ouvrir → cliquez sur « Ouvrir ». + • Ou : Réglages Système → Confidentialité et sécurité → Ouvrir quand même. + • Ou (Terminal) : xattr -cr /Applications/BlueWallet.app + Cette opération n'est nécessaire qu'une seule fois. + + ═══ العربية ════════════════════════════════════════════════════ + + التثبيت: اسحب "BlueWallet.app" إلى مجلد "التطبيقات". + + التشغيل الأول — قد يعرض macOS تحذيرًا. لفتح التطبيق: + • انقر بزر الماوس الأيمن → افتح → انقر "فتح" في مربع الحوار. + • أو: إعدادات النظام → الخصوصية والأمان → فتح على أي حال. + • أو (الطرفية): xattr -cr /Applications/BlueWallet.app + تحتاج للقيام بذلك مرة واحدة فقط. + + ──────────────────────────────────────────────────────────────── + https://bluewallet.io + README + UI.message("Added multilingual README with first-launch instructions to DMG") + + FileUtils.rm_f(dmg_path) + sh("hdiutil", "create", "-volname", "BlueWallet", "-srcfolder", dmg_staging, "-ov", "-format", "UDZO", dmg_path) + UI.user_error!("DMG was not created at #{dmg_path}") unless File.exist?(dmg_path) + UI.success("DMG created at: #{dmg_path}") + + FileUtils.rm_rf(dmg_staging) + + ENV['CATALYST_APP_PATH'] = catalyst_app_path + ENV['CATALYST_DMG_PATH'] = dmg_path + if ENV['GITHUB_OUTPUT'] + File.open(ENV['GITHUB_OUTPUT'], 'a') do |f| + f.puts "catalyst_app_path=#{catalyst_app_path}" + f.puts "catalyst_dmg_path=#{dmg_path}" + end + end + + UI.success("macOS app built at: #{catalyst_app_path}") + UI.success("macOS DMG at: #{dmg_path}") + end + end + + desc "Upload Mac Catalyst app to TestFlight" + lane :upload_catalyst_to_testflight do + Dir.chdir(project_root) do + # Locate the xcarchive + archive_path = lane_context[SharedValues::XCODEBUILD_ARCHIVE] + if archive_path.nil? || archive_path.empty? + archive_path = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive")).max_by { |path| File.mtime(path) } + end + UI.user_error!("No xcarchive found for TestFlight upload") if archive_path.nil? || !File.exist?(archive_path) + UI.message("Using archive: #{archive_path}") + + output_dir = File.join(project_root, "ios", "build", "catalyst-output") + FileUtils.mkdir_p(output_dir) + + # Export the archive as a .pkg for Mac Catalyst + team_id = ENV['CATALYST_TEAM_ID'] || ENV['TEAM_ID'] + export_plist_path = File.join(output_dir, "ExportOptions.plist") + + # Build provisioning profiles mapping for manual signing + profiles_xml = catalyst_app_identifiers.map do |app_id| + profile_name = "match AppStore #{app_id} catalyst" + "\t\t\t#{app_id}\n\t\t\t#{profile_name}" + end.join("\n") + + File.write(export_plist_path, <<~PLIST) + + + + + method + app-store + destination + upload + teamID + #{team_id} + signingStyle + manual + provisioningProfiles + +#{profiles_xml} + + + + PLIST + + pkg_path = File.join(output_dir, "BlueWallet.pkg") + sh("xcodebuild", + "-exportArchive", + "-archivePath", archive_path, + "-exportOptionsPlist", export_plist_path, + "-exportPath", output_dir) + + # Find the exported pkg + exported_pkg = Dir.glob(File.join(output_dir, "*.pkg")).first + UI.user_error!("No .pkg found after export") if exported_pkg.nil? || !File.exist?(exported_pkg) + UI.success("Exported pkg: #{exported_pkg}") + + # Build changelog + branch_name = ENV['BRANCH_NAME'] || "unknown-branch" + last_commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found" + changelog = "Build Information:\n" + changelog += "- Branch: #{branch_name}\n" if branch_name != 'master' + changelog += "- Commit: #{last_commit_message}\n" + + # Upload to TestFlight + upload_to_testflight( + api_key_path: "./appstore_api_key.json", + pkg: exported_pkg, + skip_waiting_for_build_processing: true, + changelog: changelog + ) + + UI.success("Successfully uploaded Mac Catalyst app to TestFlight!") + end + end + + + desc "Upload IPA to TestFlight" + lane :upload_to_testflight_lane do + + branch_name = ENV['BRANCH_NAME'] || "unknown-branch" + last_commit_message = ENV['LATEST_COMMIT_MESSAGE'] || "No commit message found" + + + changelog = <<~CHANGELOG + Build Information: + CHANGELOG + + # Include the branch name only if it is not 'master' + if branch_name != 'master' + changelog += <<~CHANGELOG + - Branch: #{branch_name} + CHANGELOG + end + + changelog += <<~CHANGELOG + - Commit: #{last_commit_message} + CHANGELOG + + ipa_path = ENV['IPA_OUTPUT_PATH'] + + if ipa_path.nil? || ipa_path.empty? || !File.exist?(ipa_path) + UI.user_error!("IPA file not found at path: #{ipa_path}") + end + + UI.message("Uploading IPA to TestFlight from path: #{ipa_path}") + UI.message("Changelog:\n#{changelog}") + + + upload_to_testflight( + api_key_path: "./appstore_api_key.json", + ipa: ipa_path, + skip_waiting_for_build_processing: true, + changelog: changelog + ) + + UI.success("Successfully uploaded IPA to TestFlight!") +end + +desc "Upload iOS source maps to Bugsnag" +lane :upload_bugsnag_sourcemaps do + bugsnag_api_key = ENV['BUGSNAG_API_KEY'] + bugsnag_release_stage = ENV['BUGSNAG_RELEASE_STAGE'] || "production" + version = ENV['PROJECT_VERSION'] + build_number = ENV['NEW_BUILD_NUMBER'] + + UI.user_error!("BUGSNAG_API_KEY environment variable is missing") if bugsnag_api_key.nil? + UI.user_error!("PROJECT_VERSION environment variable is missing") if version.nil? + UI.user_error!("NEW_BUILD_NUMBER environment variable is missing") if build_number.nil? + + ios_sourcemap = "./ios/build/Build/Products/Release-iphonesimulator/main.jsbundle.map" + + if File.exist?(ios_sourcemap) + UI.message("Uploading iOS source map to Bugsnag...") + bugsnag_sourcemaps_upload( + api_key: bugsnag_api_key, + source_map: ios_sourcemap, + minified_file: "./ios/main.jsbundle", + code_bundle_id: "#{version}-#{build_number}", + release_stage: bugsnag_release_stage, + app_version: version + ) + UI.success("iOS source map uploaded successfully.") + else + UI.error("iOS source map not found at #{ios_sourcemap}") + end +end + + desc "Build the iOS app" + lane :build_app_lane do + Dir.chdir(project_root) do + UI.message("Building the application from: #{Dir.pwd}") + + workspace_path = File.join(project_root, "ios", "BlueWallet.xcworkspace") + export_options_path = File.join(project_root, "ios", "export_options.plist") + + clear_derived_data_lane + + UI.message("\033[1;34m==================== FASTLANE BUILD DEBUG ====================\033[0m") + + # Comprehensive environment check + UI.message("\033[1;36mEnvironment Analysis:\033[0m") + UI.message(" Project Root: #{project_root}") + UI.message(" Current Directory: #{Dir.pwd}") + UI.message(" Ruby Version: #{RUBY_VERSION}") + UI.message(" Fastlane Version: #{Fastlane::VERSION}") + UI.message(" Build Mode: Release (App Store)") + + # Ensure we're using the correct Xcode installation + UI.message("\033[1;36mXcode Configuration:\033[0m") + begin + sh("sudo xcode-select -s /Applications/Xcode.app") rescue nil + xcode_path = sh('xcode-select -p', log: false).strip + xcode_version = sh('xcodebuild -version', log: false).strip + UI.message(" Active Xcode Path: #{xcode_path}") + UI.message(" Xcode Version: #{xcode_version}") + + # Check if Xcode path is valid + if File.exist?(File.join(xcode_path, "usr/bin/xcodebuild")) + UI.success(" \033[1;32mXcode installation is valid\033[0m") + else + UI.error(" \033[1;31mXcode installation appears invalid\033[0m") + end + rescue => e + UI.error(" \033[1;31mError checking Xcode: #{e.message}\033[0m") + end + + # Project structure analysis + UI.message("\033[1;36mProject Structure Analysis:\033[0m") + %w[ + ios/BlueWallet.xcworkspace + ios/BlueWallet.xcodeproj + ios/export_options.plist + ios/Podfile + ios/Podfile.lock + ].each do |file_path| + full_path = File.join(project_root, file_path) + if File.exist?(full_path) + UI.message(" \033[1;32m#{file_path} exists\033[0m") + if file_path.end_with?('.plist') + UI.message(" Content preview:") + content = File.read(full_path).lines.first(10).join.strip + UI.message(" #{content[0..200]}...") + end + else + UI.error(" \033[1;31m#{file_path} missing\033[0m") + end + end + + # Environment variables check + UI.message("\033[1;36mEnvironment Variables:\033[0m") + %w[PROJECT_VERSION NEW_BUILD_NUMBER].each do |var| + value = ENV[var] + if value && !value.empty? + UI.message(" \033[1;32m#{var}: #{value}\033[0m") + else + UI.error(" \033[1;31m#{var}: Not set or empty\033[0m") + end + end + + # Determine which iOS version to use + UI.message("\033[1;36miOS Version Analysis:\033[0m") + ios_version = determine_ios_version + UI.message(" Selected iOS version: #{ios_version}") + + UI.message("\033[1;36mBuild Configuration:\033[0m") + UI.message(" Workspace: #{workspace_path}") + UI.message(" Export options: #{export_options_path}") + UI.message(" Output directory: #{File.join(project_root, 'ios', 'build')}") + UI.message(" Build type: Release (generic/platform=iOS)") + + # Comprehensive destination analysis + UI.message("\033[1;33mBuild Destinations Analysis:\033[0m") + begin + destinations_output = sh("xcodebuild -workspace '#{workspace_path}' -scheme BlueWallet -showdestinations", log: false) + UI.message(" Available destinations:") + destinations_output.lines.each_with_index do |line, index| + UI.message(" #{index + 1}. #{line.strip}") if line.strip.length > 0 + end + + # Analyze destination types + ios_destinations = destinations_output.scan(/platform:iOS[^}]*/).length + catalyst_destinations = destinations_output.scan(/Mac Catalyst/).length + + UI.message(" \033[1;36mDestination Summary:\033[0m") + UI.message(" iOS destinations: #{ios_destinations}") + UI.message(" Mac Catalyst destinations: #{catalyst_destinations}") + + if ios_destinations > 0 + UI.success(" \033[1;32miOS destinations available for release build\033[0m") + else + UI.important(" \033[1;33mNo iOS destinations found - may need to create simulators\033[0m") + end + rescue => e + UI.error(" \033[1;31mFailed to get destinations: #{e.message}\033[0m") + destinations_output = "Failed to get destinations: #{e.message}" + end + + # Simulator runtime check for development/debugging + UI.message("\033[1;33mSimulator Runtime Check (for debugging):\033[0m") + begin + runtimes = sh("xcrun simctl list runtimes", log: false) + ios_runtimes = runtimes.scan(/iOS ([0-9.]+)/).flatten + UI.message(" Available iOS runtimes: #{ios_runtimes.join(', ')}") + + devices = sh("xcrun simctl list devices iOS", log: false) + iphone_count = devices.scan(/iPhone/).length + UI.message(" Available iPhone simulators: #{iphone_count}") + rescue => e + UI.error(" \033[1;31mError checking simulators: #{e.message}\033[0m") + end + + # Define the IPA output path before building + ipa_directory = File.join(project_root, "ios", "build") + ipa_name = "BlueWallet_#{ENV['PROJECT_VERSION']}_#{ENV['NEW_BUILD_NUMBER']}.ipa" + ipa_path = File.join(ipa_directory, ipa_name) + + UI.message("\033[1;36mBuild Output Configuration:\033[0m") + UI.message(" IPA Directory: #{ipa_directory}") + UI.message(" IPA Name: #{ipa_name}") + UI.message(" Full IPA Path: #{ipa_path}") + + # Ensure build directory exists + FileUtils.mkdir_p(ipa_directory) unless Dir.exist?(ipa_directory) + + begin + UI.message("🚀 Starting iOS Build Process...") + UI.message(" Build Parameters:") + UI.message(" Scheme: BlueWallet") + UI.message(" Workspace: #{workspace_path}") + UI.message(" Export Method: app-store") + UI.message(" Export Options: #{export_options_path}") + UI.message(" Output Directory: #{ipa_directory}") + UI.message(" Output Name: #{ipa_name}") + UI.message(" Build Logs: #{File.join(project_root, 'ios', 'build_logs')}") + UI.message(" Destination: generic/platform=iOS") + + # Pre-build validation + UI.message("🔍 Pre-Build Validation:") + + # Check workspace + if File.exist?(workspace_path) + UI.success(" ✅ Workspace exists") + else + UI.user_error!(" ❌ Workspace not found: #{workspace_path}") + end + + # Check export options + if File.exist?(export_options_path) + UI.success(" ✅ Export options exist") + # Read and validate export options + begin + export_content = File.read(export_options_path) + UI.message(" Export options preview:") + export_content.lines.first(5).each { |line| UI.message(" #{line.strip}") } + rescue => e + UI.error(" ⚠️ Could not read export options: #{e.message}") + end + else + UI.user_error!(" ❌ Export options not found: #{export_options_path}") + end + + build_ios_app( + scheme: "BlueWallet", + workspace: workspace_path, + export_method: "app-store", + export_options: export_options_path, + output_directory: ipa_directory, + output_name: ipa_name, + buildlog_path: File.join(project_root, "ios", "build_logs") + # Removed explicit destination - let Xcode determine the best destination for release builds + ) + + UI.success("\033[1;32miOS release build completed successfully!\033[0m") + + rescue => build_error + UI.error("\033[1;31miOS release build failed!\033[0m") + UI.error(" Error Class: #{build_error.class}") + UI.error(" Error Message: #{build_error.message}") + UI.error(" \033[1;31mError Backtrace:\033[0m") + build_error.backtrace.first(5).each { |line| UI.error(" #{line}") } + + UI.message("\033[1;33mPost-Build Debugging:\033[0m") + + # Check for partial build artifacts + build_logs_dir = File.join(project_root, "ios", "build_logs") + if Dir.exist?(build_logs_dir) + UI.message(" \033[1;36mBuild logs directory contents:\033[0m") + Dir.entries(build_logs_dir).each do |file| + next if file.start_with?('.') + file_path = File.join(build_logs_dir, file) + size = File.size(file_path) rescue 0 + UI.message(" #{file} (#{size} bytes)") + end + end + + # Check for xcarchive files + archives = Dir.glob(File.join(Dir.home, "Library/Developer/Xcode/Archives/**/*.xcarchive")) + if archives.any? + UI.message(" \033[1;36mRecent archives found:\033[0m") + archives.last(3).each { |archive| UI.message(" #{archive}") } + end + + # If iOS build fails, check if we can build using available destinations + if destinations_output.include?("Any iOS Device") + UI.message("Retrying build without explicit destination...") + build_ios_app( + scheme: "BlueWallet", + workspace: workspace_path, + export_method: "app-store", + export_options: export_options_path, + output_directory: ipa_directory, + output_name: ipa_name, + buildlog_path: File.join(project_root, "ios", "build_logs") + ) + else + UI.user_error!("build_ios_app failed: #{build_error.message}") + end + end + + # Check for IPA path from both our defined path and fastlane's context + ipa_path = lane_context[SharedValues::IPA_OUTPUT_PATH] || ipa_path + + # Ensure the directory exists + FileUtils.mkdir_p(File.dirname(ipa_path)) unless Dir.exist?(File.dirname(ipa_path)) + + if ipa_path && File.exist?(ipa_path) + UI.message("IPA successfully found at: #{ipa_path}") + else + # Try to find any IPA file as fallback + Dir.chdir(project_root) do + fallback_ipa = Dir.glob("**/*.ipa").first + if fallback_ipa + ipa_path = File.join(project_root, fallback_ipa) + UI.message("Found fallback IPA at: #{ipa_path}") + else + UI.user_error!("No IPA file found after build") + end + end + end + + # Set both environment variable and GitHub Actions output + ENV['IPA_OUTPUT_PATH'] = ipa_path + # Set both standard output format and the newer GITHUB_OUTPUT format + sh("echo 'ipa_output_path=#{ipa_path}' >> $GITHUB_OUTPUT") if ENV['GITHUB_OUTPUT'] + sh("echo ::set-output name=ipa_output_path::#{ipa_path}") + + # Also write path to a file that can be read by subsequent steps + ipa_path_file = "#{ipa_directory}/ipa_path.txt" + File.write(ipa_path_file, ipa_path) + UI.success("Saved IPA path to: #{ipa_path_file}") + end + end + + desc "Delete temporary keychain" + lane :delete_temp_keychain do + UI.message("Deleting temporary keychain...") + + delete_keychain( + name: "temp_keychain" + ) if File.exist?(File.expand_path("~/Library/Keychains/temp_keychain-db")) + + UI.message("Temporary keychain deleted successfully.") + end + + # Helper method to determine which iOS version to use + # Updated for macOS-15 compatibility (defaults to iOS 17.5 for broader compatibility) + private_lane :determine_ios_version do + UI.message("\033[1;33mDetermining iOS Version for Release Build:\033[0m") + + begin + runtimes_output = sh("xcrun simctl list runtimes 2>&1", log: false) + UI.message(" \033[1;32mSuccessfully retrieved simulator runtimes\033[0m") + + # Debug: Show all runtimes + UI.message(" \033[1;36mAll available runtimes:\033[0m") + runtimes_output.lines.first(10).each_with_index do |line, index| + UI.message(" #{index + 1}. #{line.strip}") + end + + rescue => e + UI.error(" \033[1;31mFailed to get simulator runtimes: #{e.message}\033[0m") + runtimes_output = "" + end + + if runtimes_output.include?("iOS") + UI.message(" \033[1;36miOS runtimes detected\033[0m") + + begin + ios_versions = runtimes_output.scan(/iOS ([0-9.]+)/) + .flatten + .map { |v| Gem::Version.new(v) } + .sort + .reverse + + UI.message(" \033[1;36mParsed iOS versions:\033[0m") + ios_versions.each_with_index do |version, index| + UI.message(" #{index + 1}. iOS #{version}") + end + + if ios_versions.any? + latest_version = ios_versions.first.to_s + UI.success(" \033[1;32mSelected iOS version for release: #{latest_version}\033[0m") + + # Additional validation + if Gem::Version.new(latest_version) >= Gem::Version.new("17.0") + UI.success(" \033[1;32mVersion is compatible for release builds (17.0+)\033[0m") + else + UI.important(" \033[1;33mVersion is older than 17.0, may have compatibility issues\033[0m") + end + + latest_version + else + UI.important(" \033[1;33mNo iOS versions could be parsed from runtime output\033[0m") + UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m") + "17.5" + end + rescue => e + UI.error(" \033[1;31mError parsing iOS versions: #{e.message}\033[0m") + UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m") + "17.5" + end + else + UI.important(" \033[1;33mNo iOS runtimes found in simulator list\033[0m") + UI.message(" \033[1;36mRuntime output preview:\033[0m") + runtimes_output.lines.first(5).each { |line| UI.message(" #{line.strip}") } + UI.message(" \033[1;36mUsing fallback version for release: 17.5\033[0m") + "17.5" + end + end +end + +# =========================== +# Global Lanes +# =========================== + +desc "Deploy to TestFlight" +lane :deploy do |options| + UI.message("Starting deployment process...") + + update_wwdr_certificate + setup_app_store_connect_api_key + setup_provisioning_profiles + clear_derived_data_lane + increment_build_number_lane + + unless File.directory?("Pods") + install_pods + end + + build_app_lane + upload_to_testflight_lane + + delete_keychain(name: "temp_keychain") + + last_commit = last_git_commit + already_built_flag = ".already_built_#{last_commit[:sha]}" + File.write(already_built_flag, Time.now.to_s) +end + +desc "Update release notes for App Store versions (iOS or Mac Catalyst)" +lane :release_notes do |options| + require 'spaceship' + + app = Spaceship::ConnectAPI::App.find(app_identifiers.first) + + UI.user_error!("Could not find the app with identifier: #{app_identifiers.first}") unless app + + platform_option = options[:platform] + + if platform_option + platform = case platform_option.to_s.downcase + when "ios" + UI.message("Using platform from options: iOS") + Spaceship::ConnectAPI::Platform::IOS + when "catalyst", "mac_catalyst", "mac-catalyst" + UI.message("Using platform from options: Mac Catalyst") + UI.message("Using Spaceship::ConnectAPI::Platform::MAC_OS for Mac Catalyst") + Spaceship::ConnectAPI::Platform::MAC_OS + else + UI.user_error!("Invalid platform option: #{platform_option}") + end + else + platform_selection = UI.select("Select platform for release notes:", ["iOS", "Mac Catalyst"]) + + platform = case platform_selection + when "iOS" + UI.message("Selected platform: iOS") + Spaceship::ConnectAPI::Platform::IOS + when "Mac Catalyst" + UI.message("Selected platform: Mac Catalyst") + UI.message("Using Spaceship::ConnectAPI::Platform::MAC_OS for Mac Catalyst") + Spaceship::ConnectAPI::Platform::MAC_OS + else + UI.user_error!("Invalid platform selection") + end + end + + retries = 5 + begin + rejected_version = nil + UI.message("Checking for Developer Rejected version for platform: #{platform}") + + begin + filter = { + appStoreState: "DEVELOPER_REJECTED", + platform: platform.to_s + } + + app.get_app_store_versions(filter: filter).each do |version| + rejected_version = version + UI.message("Found rejected version: #{version.version_string}") + break + end + rescue => e + UI.error("Error fetching Developer Rejected versions: #{e.message}") + UI.message("Debug info: Platform type: #{platform.class}, Value: #{platform}") + end + + if rejected_version + UI.success("Found 'Developer Rejected' version: #{rejected_version.version_string}. This will be the target for updates.") + prepare_version = rejected_version + else + UI.message("No Developer Rejected version found. Checking for version in edit mode or waiting for review...") + + begin + prepare_version = app.get_edit_app_store_version(platform: platform) + if prepare_version + UI.message("Found version in edit mode: #{prepare_version.version_string}") + else + UI.message("No version in edit mode found") + end + rescue => e + UI.error("Error fetching edit app store version: #{e.message}") + prepare_version = nil + end + + if prepare_version.nil? + UI.message("Checking for version in Waiting for Review status...") + begin + waiting_filter = { + platform: platform.to_s, + appStoreState: "WAITING_FOR_REVIEW" + } + + waiting_versions = app.get_app_store_versions(filter: waiting_filter) + if waiting_versions && !waiting_versions.empty? + prepare_version = waiting_versions.first + UI.success("Found version in Waiting for Review status: #{prepare_version.version_string}") + else + UI.message("No version in Waiting for Review status found") + end + rescue => e + UI.error("Error fetching Waiting for Review versions: #{e.message}") + end + end + + if prepare_version.nil? + UI.message("Looking for any in-flight version...") + begin + all_versions = app.get_app_store_versions(filter: { platform: platform.to_s }) + UI.message("Found #{all_versions.count} versions for platform #{platform}:") + + all_versions.each do |version| + state = app_store_state_readable(version.app_store_state) + UI.message(" - Version: #{version.version_string}, State: #{state}") + end + + editable_states = ["PREPARE_FOR_SUBMISSION", "WAITING_FOR_REVIEW", "REJECTED", "METADATA_REJECTED", "DEVELOPER_REJECTED"] + editable_version = all_versions.find { |v| editable_states.include?(v.app_store_state) } + + if editable_version + prepare_version = editable_version + UI.success("Using editable version: #{prepare_version.version_string} (#{app_store_state_readable(prepare_version.app_store_state)})") + elsif all_versions.count > 0 + latest_version = all_versions.sort_by { |v| Gem::Version.new(v.version_string) }.last + UI.message("Latest version: #{latest_version.version_string} (#{app_store_state_readable(latest_version.app_store_state)})") + end + rescue => e + UI.error("Error listing versions: #{e.message}") + end + end + + if prepare_version.nil? + UI.message("No editable version found.") + + create_new_version = UI.confirm("Would you like to create a new version?") + + if create_new_version + begin + require 'json' + package_json_path = File.expand_path("../package.json", __dir__) + package_json = JSON.parse(File.read(package_json_path)) + new_version_number = package_json["version"] + + if new_version_number.nil? || new_version_number.strip.empty? + UI.user_error!("package.json does not contain a valid version") + end + + UI.message("Using package.json version: #{new_version_number}") + UI.message("Creating new version: #{new_version_number} for platform: #{platform_selection || platform_option}") + app.ensure_version!(new_version_number, platform: platform) + + prepare_version = app.get_edit_app_store_version(platform: platform) + if prepare_version.nil? || prepare_version.version_string != new_version_number + versions_for_platform = app.get_app_store_versions(filter: { platform: platform.to_s }) + prepare_version = versions_for_platform.find { |v| v.version_string == new_version_number } + end + + UI.user_error!("Failed to fetch created version #{new_version_number}") if prepare_version.nil? + UI.message("Created new version: #{new_version_number}") + rescue => e + UI.error("Failed to create new version: #{e.message}") + UI.user_error!("Failed to create version. Make sure your app is configured for Mac Catalyst in App Store Connect.") + end + else + UI.user_error!("No editable version found and user chose not to create one. Aborting.") + end + else + UI.message("Using version #{prepare_version.version_string} in state: #{app_store_state_readable(prepare_version.app_store_state)}") + end + end + rescue => e + retries -= 1 + if retries > 0 + delay = 20 + UI.message("Cannot find app version info... Retrying after #{delay} seconds (remaining: #{retries})") + UI.error("Error details: #{e.message}") + sleep(delay) + retry + else + UI.user_error!("Failed to fetch or create the app version: #{e.message}") + end + end + + localized_metadata = prepare_version.get_app_store_version_localizations + enabled_locales = localized_metadata.map(&:locale) + release_notes_text = options[:release_notes] + + if release_notes_text.nil? || release_notes_text.strip.empty? + existing_release_notes = nil + + en_us_localization = localized_metadata.find { |loc| loc.locale == 'en-US' } + if en_us_localization && en_us_localization.whats_new && !en_us_localization.whats_new.strip.empty? + existing_release_notes = en_us_localization.whats_new + UI.success("Found existing release notes in App Store Connect!") + else + localized_metadata.each do |loc| + if loc.whats_new && !loc.whats_new.strip.empty? + existing_release_notes = loc.whats_new + UI.success("Found existing release notes in App Store Connect for locale: #{loc.locale}") + break + end + end + end + + ios_release_notes_path = "metadata/ios/en-US/release_notes.txt" + project_release_notes_path = "../release-notes.txt" + + ios_notes_exist = File.exist?(ios_release_notes_path) + project_notes_exist = File.exist?(project_release_notes_path) + + options_list = [] + + if existing_release_notes + options_list << "View/Edit existing App Store notes" + end + + options_list += [ + "Enter manually", + "Use clipboard content" + ] + + if project_notes_exist + options_list << "Use release-notes.txt file" + end + + if ios_notes_exist + options_list << "Use iOS metadata release notes" + end + + selection = UI.select("Select a source for release notes:", options_list) + + case selection + when "View/Edit existing App Store notes" + UI.message("Existing release notes:") + UI.message("-" * 50) + UI.message(existing_release_notes) + UI.message("-" * 50) + + edit_choice = UI.select("Do you want to edit these notes or use as-is?:", [ + "Use as-is", + "Edit notes" + ]) + + if edit_choice == "Edit notes" + require 'tempfile' + temp_file = Tempfile.new('release_notes') + temp_file.write(existing_release_notes) + temp_file.close + + editor = ENV['EDITOR'] || 'nano' + system("#{editor} #{temp_file.path}") + + release_notes_text = File.read(temp_file.path) + temp_file.unlink + + UI.message("Edited release notes:") + UI.message("-" * 50) + UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text) + UI.message("-" * 50) + + unless UI.confirm("Use these edited notes?") + UI.user_error!("User canceled edited notes. Aborting.") + end + else + release_notes_text = existing_release_notes + end + + when "Enter manually" + release_notes_text = UI.input("Enter the release notes:") + if release_notes_text.nil? || release_notes_text.strip.empty? + UI.user_error!("No release notes provided. Aborting.") + end + + when "Use clipboard content" + require 'open3' + stdout, stderr, status = Open3.capture3("pbpaste") + + if !status.success? || stdout.strip.empty? + UI.user_error!("Failed to get clipboard content or clipboard is empty") + end + + UI.message("Clipboard content preview:") + UI.message("-" * 50) + UI.message(stdout.length > 500 ? "#{stdout[0..500]}..." : stdout) + UI.message("-" * 50) + + unless UI.confirm("Use this clipboard content for release notes?") + UI.user_error!("User canceled clipboard content usage. Aborting.") + end + + release_notes_text = stdout + + when "Use iOS metadata release notes" + release_notes_text = File.read(ios_release_notes_path) + + UI.message("iOS metadata release notes preview:") + UI.message("-" * 50) + UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text) + UI.message("-" * 50) + + unless UI.confirm("Use this content from iOS metadata release notes?") + UI.user_error!("User canceled file content usage. Aborting.") + end + + when "Use release-notes.txt file" + release_notes_path = "../release-notes.txt" + + unless File.exist?(release_notes_path) + UI.error("Release notes file does not exist at path: #{release_notes_path}") + UI.user_error!("No release-notes.txt file found. Aborting.") + end + + release_notes_text = File.read(release_notes_path) + + UI.message("release-notes.txt content preview:") + UI.message("-" * 50) + UI.message(release_notes_text.length > 500 ? "#{release_notes_text[0..500]}..." : release_notes_text) + UI.message("-" * 50) + + unless UI.confirm("Use this content from release-notes.txt?") + UI.user_error!("User canceled file content usage. Aborting.") + end + end + end + + if release_notes_text.nil? || release_notes_text.strip.empty? + UI.user_error!("No release notes content available. Aborting.") + end + + localized_release_notes = { + 'en-US' => release_notes_text, + 'ar-SA' => release_notes_text, + 'zh-Hans' => release_notes_text, + 'hr' => release_notes_text, + 'da' => release_notes_text, + 'nl-NL' => release_notes_text, + 'fi' => release_notes_text, + 'fr-FR' => release_notes_text, + 'de-DE' => release_notes_text, + 'el' => release_notes_text, + 'he' => release_notes_text, + 'hu' => release_notes_text, + 'it' => release_notes_text, + 'ja' => release_notes_text, + 'ms' => release_notes_text, + 'nb' => release_notes_text, + 'no' => release_notes_text, + 'pl' => release_notes_text, + 'pt-BR' => release_notes_text, + 'pt-PT' => release_notes_text, + 'ro' => release_notes_text, + 'ru' => release_notes_text, + 'es-MX' => release_notes_text, + 'es-ES' => release_notes_text, + 'sv' => release_notes_text, + 'th' => release_notes_text, + } + + if platform == Spaceship::ConnectAPI::Platform::MAC_OS + UI.message("Mac Catalyst selected - using only en-US localization") + localized_release_notes = { 'en-US' => release_notes_text } + end + + localized_release_notes = localized_release_notes.select { |locale, _| enabled_locales.include?(locale) } + + UI.message("Review the following release notes updates:") + localized_release_notes.each do |locale, notes| + UI.message("Locale: #{locale} - Notes: #{notes}") + end + + force_yes = options && options.is_a?(Hash) && options[:force_yes] == true + + unless force_yes + confirm = UI.confirm("Do you want to proceed with these release notes updates?") + UI.user_error!("User aborted the lane.") unless confirm + end + + localized_release_notes.each do |locale, notes| + app_store_version_localization = localized_metadata.find { |loc| loc.locale == locale } + if app_store_version_localization + app_store_version_localization.update(attributes: { "whats_new" => notes }) + else + UI.error("No localization found for locale #{locale}") + end + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 00000000000..0fe4bdea543 --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,39 @@ +# Matchfile + +# URL of the Git repository to store the certificates +git_url(ENV["GIT_URL"]) + +# Define the type of match to run +# Default to "appstore" but can be overridden +type(ENV["MATCH_TYPE"] || "appstore") + +# App identifiers for all BlueWallet apps +app_identifier([ + "io.bluewallet.bluewallet", + "io.bluewallet.bluewallet.Stickers", + "io.bluewallet.bluewallet.MarketWidget" +]) + +# Your Apple Developer account email address +username(ENV["APPLE_ID"]) + +# The ID of your Apple Developer team +team_id(ENV["ITC_TEAM_ID"]) + +# Set readonly based on environment (default to true for safety) +# Set to false explicitly when new profiles need to be created +readonly(ENV["MATCH_READONLY"] == "false" ? false : true) + +# Define the platform to use +platform("ios") + +# Git basic authentication through access token +# This is useful for CI/CD environments where SSH keys aren't available +git_basic_authorization(ENV["GIT_ACCESS_TOKEN"]) + +# Storage mode (git by default) +storage_mode("git") + +# Optional: The Git branch that is used for match +# Default is 'master' +# branch("main") diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 00000000000..29820960deb --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,7 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-browserstack' +gem 'fastlane-plugin-bugsnag_sourcemaps_upload' +gem "fastlane-plugin-bugsnag" diff --git a/fastlane/metadata/android/ar/full_description.txt b/fastlane/metadata/android/ar/full_description.txt new file mode 120000 index 00000000000..ea069b313a7 --- /dev/null +++ b/fastlane/metadata/android/ar/full_description.txt @@ -0,0 +1 @@ +../../ios/ar-SA/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ar/short_description.txt b/fastlane/metadata/android/ar/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ar/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ar/title.txt b/fastlane/metadata/android/ar/title.txt new file mode 120000 index 00000000000..8020020fedb --- /dev/null +++ b/fastlane/metadata/android/ar/title.txt @@ -0,0 +1 @@ +../../ios/ar-SA/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/da-DK/full_description.txt b/fastlane/metadata/android/da-DK/full_description.txt new file mode 120000 index 00000000000..82916d9a3ff --- /dev/null +++ b/fastlane/metadata/android/da-DK/full_description.txt @@ -0,0 +1 @@ +../../ios/da/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/da-DK/short_description.txt b/fastlane/metadata/android/da-DK/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/da-DK/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/da-DK/title.txt b/fastlane/metadata/android/da-DK/title.txt new file mode 120000 index 00000000000..9f14d3deec4 --- /dev/null +++ b/fastlane/metadata/android/da-DK/title.txt @@ -0,0 +1 @@ +../../ios/da/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/full_description.txt b/fastlane/metadata/android/de-DE/full_description.txt new file mode 120000 index 00000000000..daf8b8d9409 --- /dev/null +++ b/fastlane/metadata/android/de-DE/full_description.txt @@ -0,0 +1 @@ +../../ios/de-DE/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/short_description.txt b/fastlane/metadata/android/de-DE/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/de-DE/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/de-DE/title.txt b/fastlane/metadata/android/de-DE/title.txt new file mode 120000 index 00000000000..a0c6245d810 --- /dev/null +++ b/fastlane/metadata/android/de-DE/title.txt @@ -0,0 +1 @@ +../../ios/de-DE/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/el-GR/full_description.txt b/fastlane/metadata/android/el-GR/full_description.txt new file mode 120000 index 00000000000..6726c33b818 --- /dev/null +++ b/fastlane/metadata/android/el-GR/full_description.txt @@ -0,0 +1 @@ +../../ios/el/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/el-GR/short_description.txt b/fastlane/metadata/android/el-GR/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/el-GR/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/el-GR/title.txt b/fastlane/metadata/android/el-GR/title.txt new file mode 120000 index 00000000000..d81052b9e9c --- /dev/null +++ b/fastlane/metadata/android/el-GR/title.txt @@ -0,0 +1 @@ +../../ios/el/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt deleted file mode 100644 index 6688b6651dd..00000000000 --- a/fastlane/metadata/android/en-US/full_description.txt +++ /dev/null @@ -1,38 +0,0 @@ -A Bitcoin wallet that allows you to store, send Bitcoin, receive Bitcoin with focus on security and simplicity. -On BlueWallet, a bitcoin wallet you own you private keys. A Bitcoin wallet made by Bitcoin users for the community. -You can instantly transact with anyone in the world and transform the financial system right from your pocket. -Create for free unlimited number of bitcoin wallets or import your existing one on your Android device. It's simple and fast. -_____ - -Here's what you get: - -1 - Security by design - -• Open Source -MIT licensed, you can build it and run it on your own! Made with ReactNative - -• Plausible deniability -Password which decrypts fake bitcoin wallets if you are forced to disclose your access - -• Full encryption -On top of the iOS multi-layer encryption, we encrypt everything with added passwords - -• SegWit & HD wallets -SegWit supported and HD wallets enable - -2 - Focused on your experience - -• Be in control -Private keys never leave your device.
You control your private keys - -• Flexible fees -Starting from 1 Satoshi. Defined by you, the user - -• Replace-By-Fee -(RBF) Speed-up your transactions by increasing the fee (BIP125) - -• Watch-only wallets -Watch-only wallets allow you to keep an eye on your cold storage without touching the hardware. - -• Lightning Network -Lightning wallet with zero-configuration. Unfairly cheap and fast transactions with the best Bitcoin user experience. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 120000 index 00000000000..6f93558ca76 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1 @@ +../../ios/en-US/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt index 1f22049f9fa..f7c06181c89 100644 --- a/fastlane/metadata/android/en-US/short_description.txt +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -1 +1 @@ -Thin Bitcoin Wallet Built with React Native and Electrum \ No newline at end of file +Radically simple, powerful & secure Bitcoin wallet. Lightning & open source. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt deleted file mode 100644 index b3071c5ffbd..00000000000 --- a/fastlane/metadata/android/en-US/title.txt +++ /dev/null @@ -1 +0,0 @@ -BlueWallet Bitcoin Wallet \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 120000 index 00000000000..7450504c6d2 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +../../ios/en-US/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-419/full_description.txt b/fastlane/metadata/android/es-419/full_description.txt new file mode 120000 index 00000000000..3eff7b27bb8 --- /dev/null +++ b/fastlane/metadata/android/es-419/full_description.txt @@ -0,0 +1 @@ +../../ios/es-MX/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-419/short_description.txt b/fastlane/metadata/android/es-419/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/es-419/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-419/title.txt b/fastlane/metadata/android/es-419/title.txt new file mode 120000 index 00000000000..c05a3293bee --- /dev/null +++ b/fastlane/metadata/android/es-419/title.txt @@ -0,0 +1 @@ +../../ios/es-MX/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/full_description.txt b/fastlane/metadata/android/es-ES/full_description.txt new file mode 120000 index 00000000000..61ba42e7348 --- /dev/null +++ b/fastlane/metadata/android/es-ES/full_description.txt @@ -0,0 +1 @@ +../../ios/es-ES/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/short_description.txt b/fastlane/metadata/android/es-ES/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/es-ES/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/es-ES/title.txt b/fastlane/metadata/android/es-ES/title.txt new file mode 120000 index 00000000000..e773d2e7fc8 --- /dev/null +++ b/fastlane/metadata/android/es-ES/title.txt @@ -0,0 +1 @@ +../../ios/es-ES/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fi-FI/full_description.txt b/fastlane/metadata/android/fi-FI/full_description.txt new file mode 120000 index 00000000000..6f7e1392617 --- /dev/null +++ b/fastlane/metadata/android/fi-FI/full_description.txt @@ -0,0 +1 @@ +../../ios/fi/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fi-FI/short_description.txt b/fastlane/metadata/android/fi-FI/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/fi-FI/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fi-FI/title.txt b/fastlane/metadata/android/fi-FI/title.txt new file mode 120000 index 00000000000..35abd48b5c5 --- /dev/null +++ b/fastlane/metadata/android/fi-FI/title.txt @@ -0,0 +1 @@ +../../ios/fi/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-CA/full_description.txt b/fastlane/metadata/android/fr-CA/full_description.txt new file mode 120000 index 00000000000..bd6294e2acd --- /dev/null +++ b/fastlane/metadata/android/fr-CA/full_description.txt @@ -0,0 +1 @@ +../../ios/fr-CA/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-CA/short_description.txt b/fastlane/metadata/android/fr-CA/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/fr-CA/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-CA/title.txt b/fastlane/metadata/android/fr-CA/title.txt new file mode 120000 index 00000000000..a3626728d3e --- /dev/null +++ b/fastlane/metadata/android/fr-CA/title.txt @@ -0,0 +1 @@ +../../ios/fr-CA/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/full_description.txt b/fastlane/metadata/android/fr-FR/full_description.txt new file mode 120000 index 00000000000..6dc14a53b60 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/full_description.txt @@ -0,0 +1 @@ +../../ios/fr-FR/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/short_description.txt b/fastlane/metadata/android/fr-FR/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/fr-FR/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/fr-FR/title.txt b/fastlane/metadata/android/fr-FR/title.txt new file mode 120000 index 00000000000..4a42842eb5e --- /dev/null +++ b/fastlane/metadata/android/fr-FR/title.txt @@ -0,0 +1 @@ +../../ios/fr-FR/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/hu-HU/full_description.txt b/fastlane/metadata/android/hu-HU/full_description.txt new file mode 120000 index 00000000000..17ae9318bb6 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/full_description.txt @@ -0,0 +1 @@ +../../ios/hu/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/hu-HU/short_description.txt b/fastlane/metadata/android/hu-HU/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/hu-HU/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/hu-HU/title.txt b/fastlane/metadata/android/hu-HU/title.txt new file mode 120000 index 00000000000..9e47c76b30b --- /dev/null +++ b/fastlane/metadata/android/hu-HU/title.txt @@ -0,0 +1 @@ +../../ios/hu/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/id/full_description.txt b/fastlane/metadata/android/id/full_description.txt new file mode 120000 index 00000000000..dea4221da31 --- /dev/null +++ b/fastlane/metadata/android/id/full_description.txt @@ -0,0 +1 @@ +../../ios/id/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/id/short_description.txt b/fastlane/metadata/android/id/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/id/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/id/title.txt b/fastlane/metadata/android/id/title.txt new file mode 120000 index 00000000000..ea79c7d13c6 --- /dev/null +++ b/fastlane/metadata/android/id/title.txt @@ -0,0 +1 @@ +../../ios/id/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/full_description.txt b/fastlane/metadata/android/it-IT/full_description.txt new file mode 120000 index 00000000000..0be99dd81ff --- /dev/null +++ b/fastlane/metadata/android/it-IT/full_description.txt @@ -0,0 +1 @@ +../../ios/it/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/short_description.txt b/fastlane/metadata/android/it-IT/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/it-IT/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/it-IT/title.txt b/fastlane/metadata/android/it-IT/title.txt new file mode 120000 index 00000000000..afeb33236a9 --- /dev/null +++ b/fastlane/metadata/android/it-IT/title.txt @@ -0,0 +1 @@ +../../ios/it/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/iw-IL/full_description.txt b/fastlane/metadata/android/iw-IL/full_description.txt new file mode 120000 index 00000000000..28fb15e33d1 --- /dev/null +++ b/fastlane/metadata/android/iw-IL/full_description.txt @@ -0,0 +1 @@ +../../ios/he/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/iw-IL/short_description.txt b/fastlane/metadata/android/iw-IL/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/iw-IL/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/iw-IL/title.txt b/fastlane/metadata/android/iw-IL/title.txt new file mode 120000 index 00000000000..4e204c471b9 --- /dev/null +++ b/fastlane/metadata/android/iw-IL/title.txt @@ -0,0 +1 @@ +../../ios/he/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/full_description.txt b/fastlane/metadata/android/ja-JP/full_description.txt new file mode 120000 index 00000000000..105dc5b67af --- /dev/null +++ b/fastlane/metadata/android/ja-JP/full_description.txt @@ -0,0 +1 @@ +../../ios/ja/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/short_description.txt b/fastlane/metadata/android/ja-JP/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ja-JP/title.txt b/fastlane/metadata/android/ja-JP/title.txt new file mode 120000 index 00000000000..1782c7782d5 --- /dev/null +++ b/fastlane/metadata/android/ja-JP/title.txt @@ -0,0 +1 @@ +../../ios/ja/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/full_description.txt b/fastlane/metadata/android/ko-KR/full_description.txt new file mode 120000 index 00000000000..e3228741029 --- /dev/null +++ b/fastlane/metadata/android/ko-KR/full_description.txt @@ -0,0 +1 @@ +../../ios/ko/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/short_description.txt b/fastlane/metadata/android/ko-KR/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ko-KR/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ko-KR/title.txt b/fastlane/metadata/android/ko-KR/title.txt new file mode 120000 index 00000000000..3b41bd8dfad --- /dev/null +++ b/fastlane/metadata/android/ko-KR/title.txt @@ -0,0 +1 @@ +../../ios/ko/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ms/full_description.txt b/fastlane/metadata/android/ms/full_description.txt new file mode 120000 index 00000000000..866779872d5 --- /dev/null +++ b/fastlane/metadata/android/ms/full_description.txt @@ -0,0 +1 @@ +../../ios/ms/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ms/short_description.txt b/fastlane/metadata/android/ms/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ms/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ms/title.txt b/fastlane/metadata/android/ms/title.txt new file mode 120000 index 00000000000..60d57a8b0b2 --- /dev/null +++ b/fastlane/metadata/android/ms/title.txt @@ -0,0 +1 @@ +../../ios/ms/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/nl-NL/full_description.txt b/fastlane/metadata/android/nl-NL/full_description.txt new file mode 120000 index 00000000000..a1cb652c58d --- /dev/null +++ b/fastlane/metadata/android/nl-NL/full_description.txt @@ -0,0 +1 @@ +../../ios/nl-NL/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/nl-NL/short_description.txt b/fastlane/metadata/android/nl-NL/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/nl-NL/title.txt b/fastlane/metadata/android/nl-NL/title.txt new file mode 120000 index 00000000000..73bc3237a72 --- /dev/null +++ b/fastlane/metadata/android/nl-NL/title.txt @@ -0,0 +1 @@ +../../ios/nl-NL/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/no-NO/full_description.txt b/fastlane/metadata/android/no-NO/full_description.txt new file mode 120000 index 00000000000..e93a9fb96b4 --- /dev/null +++ b/fastlane/metadata/android/no-NO/full_description.txt @@ -0,0 +1 @@ +../../ios/no/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/no-NO/short_description.txt b/fastlane/metadata/android/no-NO/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/no-NO/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/no-NO/title.txt b/fastlane/metadata/android/no-NO/title.txt new file mode 120000 index 00000000000..e0c1a9c21db --- /dev/null +++ b/fastlane/metadata/android/no-NO/title.txt @@ -0,0 +1 @@ +../../ios/no/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/full_description.txt b/fastlane/metadata/android/pl-PL/full_description.txt new file mode 120000 index 00000000000..f15c23529c0 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/full_description.txt @@ -0,0 +1 @@ +../../ios/pl/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/short_description.txt b/fastlane/metadata/android/pl-PL/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pl-PL/title.txt b/fastlane/metadata/android/pl-PL/title.txt new file mode 120000 index 00000000000..bc201b7c6c0 --- /dev/null +++ b/fastlane/metadata/android/pl-PL/title.txt @@ -0,0 +1 @@ +../../ios/pl/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt new file mode 120000 index 00000000000..93f4f094990 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -0,0 +1 @@ +../../ios/pt-BR/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/short_description.txt b/fastlane/metadata/android/pt-BR/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/pt-BR/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-BR/title.txt b/fastlane/metadata/android/pt-BR/title.txt new file mode 120000 index 00000000000..64ca1b7bccc --- /dev/null +++ b/fastlane/metadata/android/pt-BR/title.txt @@ -0,0 +1 @@ +../../ios/pt-BR/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-PT/full_description.txt b/fastlane/metadata/android/pt-PT/full_description.txt new file mode 120000 index 00000000000..93a657fc63f --- /dev/null +++ b/fastlane/metadata/android/pt-PT/full_description.txt @@ -0,0 +1 @@ +../../ios/pt-PT/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-PT/short_description.txt b/fastlane/metadata/android/pt-PT/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/pt-PT/title.txt b/fastlane/metadata/android/pt-PT/title.txt new file mode 120000 index 00000000000..bc8c61a2785 --- /dev/null +++ b/fastlane/metadata/android/pt-PT/title.txt @@ -0,0 +1 @@ +../../ios/pt-PT/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ro/full_description.txt b/fastlane/metadata/android/ro/full_description.txt new file mode 120000 index 00000000000..34396133718 --- /dev/null +++ b/fastlane/metadata/android/ro/full_description.txt @@ -0,0 +1 @@ +../../ios/ro/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ro/short_description.txt b/fastlane/metadata/android/ro/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ro/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ro/title.txt b/fastlane/metadata/android/ro/title.txt new file mode 120000 index 00000000000..c603fce4ddc --- /dev/null +++ b/fastlane/metadata/android/ro/title.txt @@ -0,0 +1 @@ +../../ios/ro/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 120000 index 00000000000..ecfcc00e32d --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1 @@ +../../ios/ru/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 120000 index 00000000000..d559c607fe2 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +../../ios/ru/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/sv-SE/full_description.txt b/fastlane/metadata/android/sv-SE/full_description.txt new file mode 120000 index 00000000000..1c4972ca76c --- /dev/null +++ b/fastlane/metadata/android/sv-SE/full_description.txt @@ -0,0 +1 @@ +../../ios/sv/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/sv-SE/short_description.txt b/fastlane/metadata/android/sv-SE/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/sv-SE/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/sv-SE/title.txt b/fastlane/metadata/android/sv-SE/title.txt new file mode 120000 index 00000000000..b9b7b007ead --- /dev/null +++ b/fastlane/metadata/android/sv-SE/title.txt @@ -0,0 +1 @@ +../../ios/sv/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/th/full_description.txt b/fastlane/metadata/android/th/full_description.txt new file mode 120000 index 00000000000..1c44c469d85 --- /dev/null +++ b/fastlane/metadata/android/th/full_description.txt @@ -0,0 +1 @@ +../../ios/th/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/th/short_description.txt b/fastlane/metadata/android/th/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/th/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/th/title.txt b/fastlane/metadata/android/th/title.txt new file mode 120000 index 00000000000..5e0ca4e68e7 --- /dev/null +++ b/fastlane/metadata/android/th/title.txt @@ -0,0 +1 @@ +../../ios/th/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/full_description.txt b/fastlane/metadata/android/tr-TR/full_description.txt new file mode 120000 index 00000000000..27e9c7f084a --- /dev/null +++ b/fastlane/metadata/android/tr-TR/full_description.txt @@ -0,0 +1 @@ +../../ios/tr/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/short_description.txt b/fastlane/metadata/android/tr-TR/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/tr-TR/title.txt b/fastlane/metadata/android/tr-TR/title.txt new file mode 120000 index 00000000000..24d0ff439e6 --- /dev/null +++ b/fastlane/metadata/android/tr-TR/title.txt @@ -0,0 +1 @@ +../../ios/tr/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/vi/full_description.txt b/fastlane/metadata/android/vi/full_description.txt new file mode 120000 index 00000000000..069fb163e13 --- /dev/null +++ b/fastlane/metadata/android/vi/full_description.txt @@ -0,0 +1 @@ +../../ios/vi/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/vi/short_description.txt b/fastlane/metadata/android/vi/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/vi/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/vi/title.txt b/fastlane/metadata/android/vi/title.txt new file mode 120000 index 00000000000..f7794d68000 --- /dev/null +++ b/fastlane/metadata/android/vi/title.txt @@ -0,0 +1 @@ +../../ios/vi/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt new file mode 120000 index 00000000000..bd5aa0f020b --- /dev/null +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -0,0 +1 @@ +../../ios/zh-Hans/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/short_description.txt b/fastlane/metadata/android/zh-CN/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-CN/title.txt b/fastlane/metadata/android/zh-CN/title.txt new file mode 120000 index 00000000000..41d43f228a0 --- /dev/null +++ b/fastlane/metadata/android/zh-CN/title.txt @@ -0,0 +1 @@ +../../ios/zh-Hans/name.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/full_description.txt b/fastlane/metadata/android/zh-TW/full_description.txt new file mode 120000 index 00000000000..4cd33a4b63d --- /dev/null +++ b/fastlane/metadata/android/zh-TW/full_description.txt @@ -0,0 +1 @@ +../../ios/zh-Hant/description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/short_description.txt b/fastlane/metadata/android/zh-TW/short_description.txt new file mode 120000 index 00000000000..38134fe1878 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/short_description.txt @@ -0,0 +1 @@ +../en-US/short_description.txt \ No newline at end of file diff --git a/fastlane/metadata/android/zh-TW/title.txt b/fastlane/metadata/android/zh-TW/title.txt new file mode 120000 index 00000000000..c43394c9170 --- /dev/null +++ b/fastlane/metadata/android/zh-TW/title.txt @@ -0,0 +1 @@ +../../ios/zh-Hant/name.txt \ No newline at end of file diff --git a/ios/fastlane/metadata/app_icon.jpg b/fastlane/metadata/ios/app_icon.jpg similarity index 100% rename from ios/fastlane/metadata/app_icon.jpg rename to fastlane/metadata/ios/app_icon.jpg diff --git a/ios/fastlane/metadata/ar-SA/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/ar-SA/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/ar-SA/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/ar-SA/description.txt b/fastlane/metadata/ios/ar-SA/description.txt new file mode 100644 index 00000000000..a4ab8a0764f --- /dev/null +++ b/fastlane/metadata/ios/ar-SA/description.txt @@ -0,0 +1,56 @@ +BlueWallet محفظة Bitcoin بسيطة للغاية وقوية وآمنة. خزّن البتكوين وأرسله واستقبله مع بقائك متحكمًا تمامًا — مفاتيحك، عملاتك. + +مجانية ومفتوحة المصدر، صنعها مستخدمو Bitcoin من أجل المجتمع. أجرِ المعاملات مع أي شخص في العالم وتولَّ زمام أموالك من جيبك مباشرةً. أنشئ عددًا غير محدود من المحافظ مجانًا أو استورد محفظة موجودة — الأمر سريع وبسيط. + +_____ + +الأمان بحكم التصميم + +مفتوح المصدر +مرخّص بموجب MIT — دقّقه وابنِه وشغّله بنفسك. مصمَّم باستخدام React Native. + +مفاتيحك، عملاتك +المفاتيح الخاصة (عبارة الاسترداد) لا تغادر جهازك أبدًا. تبقى أنت المتحكم دائمًا. + +خزنة متعددة التواقيع +أفضل حماية متاحة على Bitcoin — تتطلب عدة مفاتيح للإنفاق، تمامًا كخزنة حقيقية. + +التشفير الكامل +علاوةً على تشفير الجهاز، يُشفَّر كل شيء مرة أخرى بكلمة المرور الخاصة بك. + +الإنكار المقبول +عيّن كلمة مرور وهمية تفتح محافظ مزيفة إن أُجبرت يومًا على فك القفل. + +_____ + +القوة والتحكم + +محافظ حديثة +محافظ HD مع دعم كامل لعناوين Legacy وSegWit وTaproot. + +محافظ الأجهزة +اقرن Coldcard وKeystone وغيرها من موقّعي PSBT، واحتفظ بعملاتك في التخزين البارد. + +محافظ للقراءة فقط +راقب تخزينك البارد دون كشف مفاتيحك الخاصة أبدًا. + +شغّل النود الخاص بك +اتصل بالنود التام الخاص بك عبر Electrum، ووجّه اتصالك عبر Tor. + +التحكم في العملات +اعرض عملاتك (UTXOs) وضع لها علامات وجمّدها لبناء المعاملات تمامًا كما تريد. + +رسوم مرنة +حدّد رسومك الخاصة، حتى 1 sat/vByte. سرّع المعاملات العالقة عبر RBF أو CPFP، أو ألغِها. + +_____ + +شبكة Lightning + +مدفوعات رخيصة بشكل لا يُصدَّق وسريعة للغاية دون أي إعداد. ادفع واستقبل المدفوعات عبر عناوين Lightning وLNURL. + +_____ + +متوفرة على iOS وAndroid وmacOS. عملات ولغات متعددة. الوضع الداكن. لا حسابات ولا تتبع — Bitcoin فقط. + +BlueWallet — امتلك بتكوينك. \ No newline at end of file diff --git a/fastlane/metadata/ios/ar-SA/keywords.txt b/fastlane/metadata/ios/ar-SA/keywords.txt new file mode 100644 index 00000000000..bf7dbea88ac --- /dev/null +++ b/fastlane/metadata/ios/ar-SA/keywords.txt @@ -0,0 +1 @@ +bitcoin,محفظة,محفظة bitcoin,سلسلة الكتل,btc,عملة رقمية,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/ar-SA/marketing_url.txt b/fastlane/metadata/ios/ar-SA/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/marketing_url.txt rename to fastlane/metadata/ios/ar-SA/marketing_url.txt diff --git a/fastlane/metadata/ios/ar-SA/name.txt b/fastlane/metadata/ios/ar-SA/name.txt new file mode 100644 index 00000000000..8d236b825b9 --- /dev/null +++ b/fastlane/metadata/ios/ar-SA/name.txt @@ -0,0 +1 @@ +BlueWallet - محفظة بتكوين diff --git a/ios/fastlane/metadata/ar-SA/privacy_url.txt b/fastlane/metadata/ios/ar-SA/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/privacy_url.txt rename to fastlane/metadata/ios/ar-SA/privacy_url.txt diff --git a/fastlane/metadata/ios/ar-SA/promotional_text.txt b/fastlane/metadata/ios/ar-SA/promotional_text.txt new file mode 100644 index 00000000000..82a16013e43 --- /dev/null +++ b/fastlane/metadata/ios/ar-SA/promotional_text.txt @@ -0,0 +1,10 @@ +المزايا + +* مفتوح المصدر +* التشفير الكامل +* الإنكار المقبول +* رسوم مرنة +* الاستبدال بالرسوم (RBF) +* SegWit +* محافظ للقراءة فقط +* شبكة Lightning diff --git a/ios/fastlane/metadata/ar-SA/release_notes.txt b/fastlane/metadata/ios/ar-SA/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/release_notes.txt rename to fastlane/metadata/ios/ar-SA/release_notes.txt diff --git a/ios/fastlane/metadata/ar-SA/subtitle.txt b/fastlane/metadata/ios/ar-SA/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/subtitle.txt rename to fastlane/metadata/ios/ar-SA/subtitle.txt diff --git a/ios/fastlane/metadata/ar-SA/support_url.txt b/fastlane/metadata/ios/ar-SA/support_url.txt similarity index 100% rename from ios/fastlane/metadata/ar-SA/support_url.txt rename to fastlane/metadata/ios/ar-SA/support_url.txt diff --git a/fastlane/metadata/ios/copyright.txt b/fastlane/metadata/ios/copyright.txt new file mode 100644 index 00000000000..611e60f6ca0 --- /dev/null +++ b/fastlane/metadata/ios/copyright.txt @@ -0,0 +1 @@ +2024 BlueWallet Services S.R.L. diff --git a/fastlane/metadata/ios/cs/description.txt b/fastlane/metadata/ios/cs/description.txt new file mode 100644 index 00000000000..b382095aa2f --- /dev/null +++ b/fastlane/metadata/ios/cs/description.txt @@ -0,0 +1,56 @@ +BlueWallet je radikálně jednoduchá a bezpečná bitcoinová peněženka. Ukládejte, odesílejte a přijímejte bitcoin a mějte nad ním plnou kontrolu – vaše klíče, vaše mince. + +Je zdarma a s otevřeným kódem, vytvořena uživateli bitcoinu pro bitcoinovou komunitu. Provádějte transakce s kýmkoliv na světě a převezměte kontrolu nad svými penězi – vše máte na dosah ruky. Vytvářejte bezplatně neomezený počet peněženek, nebo importujte existující – rychle a jednoduše. + +_____ + +NAVRŽENA S BEZPEČNOSTÍ NA PAMĚTI + +Má otevřený kód +Je licencována licencí MIT – můžete ji auditovat, sestavit a spustit vy sami. Vytvořena s React Native. + +Vaše klíče, vaše mince +Soukromé klíče (váš seed) nikdy neopustí vaše zařízení. Vy nad nimi máte plnou kontrolu. + +Vícepodpisové Úložiště +Jde o nejlepší možné zabezpečení bitcoinu – k jeho utracení je zapotřebí použít více klíčů, stejně jako u skutečného trezoru. + +Plně šifrovaná aplikace +Kromě toho, že je šifrované vaše zařízení, jsou data aplikace zašifrována ještě jednou vaším vlastním heslem. + +Věrohodná popiratelnost +Můžete nastavit návnadové heslo, které otevře falešné peněženky pro případ, že byste byli donuceni zařízení a aplikaci odemknout. + +_____ + +KONTROLA + +Moderní peněženky +HD (hierarchicky deterministické) peněženky s plnou podporou Legacy, SegWit a Taproot adres. + +Hardwarové peněženky +Spárujte Coldcard, Keystone a jiné PSBT podepisovače a držte své mince v cold storage. + +Peněženky pouze ke sledování +Sledujte své prostředky na cold storage, aniž byste odhalili své soukromé klíče. + +Provozujte svůj vlastní uzel +Připojte se ke svému plnému bitcoinovému uzlu přes Electrum a směrujte připojení skrze Tor. + +Kontrola nad mincemi +Prohlížejte, označujte a zmrazujte své mince (UTXO) tak, abyste sestavili transakce přesně tak, jak chcete. + +Flexibilní poplatky +Nastavujte vlastní poplatky až na 1 sat/vByte. Urychlete zaseknuté transakce pomocí RBF nebo CPFP, nebo je zrušte. + +_____ + +SÍŤ LIGHTNING + +Neférově levné a bleskově rychlé platby s nulovou konfigurací. Plaťte a přijímejte platby pomocí Lightning adres a LNURL. + +_____ + +Dostupné na iOS, Androidu a macOS. Vícero měn a podporovaných jazyků. Tmavý motiv. Žádné účty a sledování – čistě bitcoin. + +BlueWallet – vlastněte svůj bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/cs/keywords.txt b/fastlane/metadata/ios/cs/keywords.txt new file mode 100644 index 00000000000..f14f75635c2 --- /dev/null +++ b/fastlane/metadata/ios/cs/keywords.txt @@ -0,0 +1 @@ +bitcoin,peněženka,bitcoinová peněženka,blockchain,btc,kryptoměna,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/cs/name.txt b/fastlane/metadata/ios/cs/name.txt new file mode 100644 index 00000000000..1d63853761b --- /dev/null +++ b/fastlane/metadata/ios/cs/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin peněženka diff --git a/fastlane/metadata/ios/cs/promotional_text.txt b/fastlane/metadata/ios/cs/promotional_text.txt new file mode 100644 index 00000000000..012327f16b4 --- /dev/null +++ b/fastlane/metadata/ios/cs/promotional_text.txt @@ -0,0 +1,10 @@ +Funkce + +* Otevřený kód +* Plně šifrovaná +* Věrohodná popiratelnost +* Flexibilní poplatky +* Replace-By-Fee (RBF) +* SegWit +* Peněženky pouze ke sledování +* Síť Lightning diff --git a/ios/fastlane/metadata/da/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/da/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/da/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/da/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/da/description.txt b/fastlane/metadata/ios/da/description.txt new file mode 100644 index 00000000000..8ea35a2b87f --- /dev/null +++ b/fastlane/metadata/ios/da/description.txt @@ -0,0 +1,56 @@ +BlueWallet er en radikalt enkel, kraftfuld og sikker Bitcoin-wallet. Opbevar, send og modtag Bitcoin, mens du bevarer fuld kontrol — dine nøgler, dine coins. + +Gratis og open source, bygget af Bitcoin-brugere for fællesskabet. Handl med hvem som helst i verden og tag styringen over dine penge, direkte fra lommen. Opret ubegrænsede wallets gratis eller importér en eksisterende — det er hurtigt og enkelt. + +_____ + +INDBYGGET SIKKERHED + +Open source +MIT-licenseret — gennemgå den, byg den og kør den selv. Lavet med React Native. + +Dine nøgler, dine coins +Private nøgler (din seed) forlader aldrig din enhed. Du har altid kontrollen. + +Multisig Vault +Den bedste sikkerhed på Bitcoin — der kræves flere nøgler for at bruge midler, præcis som en ægte boks. + +Fuld kryptering +Ud over enhedens kryptering bliver alt krypteret igen med din egen adgangskode. + +Sandsynlig benægtelse +Indstil en lokkeadgangskode, der åbner falske wallets, hvis du nogensinde tvinges til at låse op. + +_____ + +KRAFT OG KONTROL + +Moderne wallets +HD-wallets med fuld understøttelse af Legacy-, SegWit- og Taproot-adresser. + +Hardware wallets +Par Coldcard, Keystone og andre PSBT-signere, og hold dine coins i kold opbevaring. + +Watch-only-wallets +Hold øje med din kolde opbevaring uden nogensinde at afsløre dine private nøgler. + +Kør din egen node +Forbind til din egen Bitcoin-fuldknude via Electrum, og send din forbindelse gennem Tor. + +Coin Control +Se, mærk og frys dine coins (UTXO'er) for at bygge transaktioner præcis som du vil. + +Fleksible gebyrer +Sæt dit eget gebyr, helt ned til 1 sat/vByte. Fremskynd fastlåste transaktioner med RBF eller CPFP, eller annullér dem. + +_____ + +LIGHTNING NETWORK + +Urimeligt billige og lynhurtige betalinger uden nogen konfiguration. Betal og bliv betalt med Lightning-adresser og LNURL. + +_____ + +Tilgængelig på iOS, Android og macOS. Flere valutaer og sprog. Mørk tilstand. Ingen konti og ingen sporing — bare Bitcoin. + +BlueWallet — ej din Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/da/keywords.txt b/fastlane/metadata/ios/da/keywords.txt new file mode 100644 index 00000000000..51205c740fc --- /dev/null +++ b/fastlane/metadata/ios/da/keywords.txt @@ -0,0 +1 @@ +bitcoin,wallet,bitcoin wallet,blockchain,btc,kryptovaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/da/marketing_url.txt b/fastlane/metadata/ios/da/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/da/marketing_url.txt rename to fastlane/metadata/ios/da/marketing_url.txt diff --git a/ios/fastlane/metadata/da/name.txt b/fastlane/metadata/ios/da/name.txt similarity index 100% rename from ios/fastlane/metadata/da/name.txt rename to fastlane/metadata/ios/da/name.txt diff --git a/ios/fastlane/metadata/da/privacy_url.txt b/fastlane/metadata/ios/da/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/da/privacy_url.txt rename to fastlane/metadata/ios/da/privacy_url.txt diff --git a/fastlane/metadata/ios/da/promotional_text.txt b/fastlane/metadata/ios/da/promotional_text.txt new file mode 100644 index 00000000000..aa863e1d247 --- /dev/null +++ b/fastlane/metadata/ios/da/promotional_text.txt @@ -0,0 +1,10 @@ +Funktioner + +* Open Source +* Fuld kryptering +* Sandsynlig benægtelse +* Fleksible gebyrer +* Replace-By-Fee (RBF) +* SegWit +* Watch-only-wallets +* Lightning Network diff --git a/ios/fastlane/metadata/da/release_notes.txt b/fastlane/metadata/ios/da/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/da/release_notes.txt rename to fastlane/metadata/ios/da/release_notes.txt diff --git a/ios/fastlane/metadata/da/subtitle.txt b/fastlane/metadata/ios/da/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/da/subtitle.txt rename to fastlane/metadata/ios/da/subtitle.txt diff --git a/ios/fastlane/metadata/da/support_url.txt b/fastlane/metadata/ios/da/support_url.txt similarity index 100% rename from ios/fastlane/metadata/da/support_url.txt rename to fastlane/metadata/ios/da/support_url.txt diff --git a/ios/fastlane/metadata/de-DE/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/de-DE/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/de-DE/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/de-DE/description.txt b/fastlane/metadata/ios/de-DE/description.txt new file mode 100644 index 00000000000..d60caf83af1 --- /dev/null +++ b/fastlane/metadata/ios/de-DE/description.txt @@ -0,0 +1,56 @@ +BlueWallet ist eine radikal einfache, leistungsstarke und sichere Bitcoin-Wallet. Bewahre Bitcoin auf, sende und empfange ihn und behalte die volle Kontrolle — deine Schlüssel, deine Coins. + +Kostenlos und quelloffen, von Bitcoin-Nutzern für die Community entwickelt. Mache Geschäfte mit jedem auf der Welt und übernimm die Kontrolle über dein Geld, direkt aus deiner Tasche. Erstelle kostenlos unbegrenzt viele Wallets oder importiere eine bestehende — schnell und einfach. + +_____ + +SICHERHEIT VON GRUND AUF + +Quelloffen +MIT-lizenziert — prüfe, kompiliere und betreibe sie selbst. Erstellt mit React Native. + +Deine Schlüssel, deine Coins +Private Schlüssel (dein Seed) verlassen niemals dein Gerät. Du hast immer die Kontrolle. + +Multisig-Tresor +Die beste Sicherheit, die es bei Bitcoin gibt — zum Ausgeben sind mehrere Schlüssel nötig, genau wie bei einem echten Tresor. + +Vollständige Verschlüsselung +Zusätzlich zur Geräteverschlüsselung wird alles noch einmal mit deinem eigenen Passwort verschlüsselt. + +Glaubhafte Abstreitbarkeit +Lege ein Täuschungspasswort fest, das gefälschte Wallets öffnet, falls du jemals zum Entsperren gezwungen wirst. + +_____ + +LEISTUNG UND KONTROLLE + +Moderne Wallets +HD-Wallets mit voller Unterstützung für Legacy-, SegWit- und Taproot-Adressen. + +Hardware Wallets +Verbinde Coldcard, Keystone und andere PSBT-Signierer und bewahre deine Coins im Cold Storage auf. + +Watch-only-Wallets +Behalte dein Cold Storage im Blick, ohne jemals deine privaten Schlüssel preiszugeben. + +Betreibe deinen eigenen Node +Verbinde dich über Electrum mit deinem eigenen Bitcoin-Full-Node und leite deine Verbindung über Tor. + +Münzkontrolle +Sieh, beschrifte und friere deine Coins (UTXOs) ein, um Transaktionen genau so zu erstellen, wie du es möchtest. + +Flexible Gebühren +Lege deine eigene Gebühr fest, bis hinunter zu 1 sat/vByte. Beschleunige festhängende Transaktionen mit RBF oder CPFP oder storniere sie. + +_____ + +LIGHTNING-NETZWERK + +Unfair günstige und blitzschnelle Zahlungen ganz ohne Konfiguration. Zahle und werde bezahlt mit Lightning-Adressen und LNURL. + +_____ + +Verfügbar für iOS, Android und macOS. Mehrere Währungen und Sprachen. Dark Mode. Keine Konten und kein Tracking — nur Bitcoin. + +BlueWallet — besitze deinen Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/de-DE/keywords.txt b/fastlane/metadata/ios/de-DE/keywords.txt new file mode 100644 index 00000000000..46e37ed8fe9 --- /dev/null +++ b/fastlane/metadata/ios/de-DE/keywords.txt @@ -0,0 +1 @@ +Bitcoin,Wallet,Bitcoin Wallet,Blockchain,BTC,Kryptowährung,Lightning,SegWit,Multisig,Electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/de-DE/marketing_url.txt b/fastlane/metadata/ios/de-DE/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/marketing_url.txt rename to fastlane/metadata/ios/de-DE/marketing_url.txt diff --git a/fastlane/metadata/ios/de-DE/name.txt b/fastlane/metadata/ios/de-DE/name.txt new file mode 100644 index 00000000000..265118bec8e --- /dev/null +++ b/fastlane/metadata/ios/de-DE/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin Wallet diff --git a/ios/fastlane/metadata/de-DE/privacy_url.txt b/fastlane/metadata/ios/de-DE/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/privacy_url.txt rename to fastlane/metadata/ios/de-DE/privacy_url.txt diff --git a/fastlane/metadata/ios/de-DE/promotional_text.txt b/fastlane/metadata/ios/de-DE/promotional_text.txt new file mode 100644 index 00000000000..a8f275eb23a --- /dev/null +++ b/fastlane/metadata/ios/de-DE/promotional_text.txt @@ -0,0 +1,10 @@ +Funktionen + +* Quelloffen +* Volle Verschlüsselung +* Glaubhafte Täuschung +* Flexible Gebühren +* Replace-By-Fee (RBF) +* SegWit +* Watch-only-Wallets +* Lightning-Netzwerk diff --git a/ios/fastlane/metadata/de-DE/release_notes.txt b/fastlane/metadata/ios/de-DE/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/release_notes.txt rename to fastlane/metadata/ios/de-DE/release_notes.txt diff --git a/ios/fastlane/metadata/de-DE/subtitle.txt b/fastlane/metadata/ios/de-DE/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/subtitle.txt rename to fastlane/metadata/ios/de-DE/subtitle.txt diff --git a/ios/fastlane/metadata/de-DE/support_url.txt b/fastlane/metadata/ios/de-DE/support_url.txt similarity index 100% rename from ios/fastlane/metadata/de-DE/support_url.txt rename to fastlane/metadata/ios/de-DE/support_url.txt diff --git a/fastlane/metadata/ios/el/description.txt b/fastlane/metadata/ios/el/description.txt new file mode 100644 index 00000000000..cac4e1ac922 --- /dev/null +++ b/fastlane/metadata/ios/el/description.txt @@ -0,0 +1,56 @@ +Το BlueWallet είναι ένα ριζικά απλό, ισχυρό και ασφαλές πορτοφόλι Bitcoin. Αποθήκευσε, στείλε και λάβε Bitcoin διατηρώντας τον πλήρη έλεγχο — τα κλειδιά σου, τα νομίσματά σου. + +Δωρεάν και ανοιχτού κώδικα, φτιαγμένο από χρήστες του Bitcoin για την κοινότητα. Συναλλάξου με οποιονδήποτε στον κόσμο και πάρε τον έλεγχο των χρημάτων σου, μέσα από την τσέπη σου. Δημιούργησε απεριόριστα πορτοφόλια δωρεάν ή εισήγαγε ένα υπάρχον — είναι γρήγορο και απλό. + +_____ + +ΑΣΦΑΛΕΙΑ ΕΚ ΣΧΕΔΙΑΣΜΟΥ + +Ανοιχτού κώδικα +Με άδεια MIT — έλεγξέ το, χτίσε το και τρέξε το μόνος σου. Φτιαγμένο με React Native. + +Τα κλειδιά σου, τα νομίσματά σου +Τα ιδιωτικά κλειδιά (η μνημονική σου φράση) δεν φεύγουν ποτέ από τη συσκευή σου. Έχεις πάντα τον έλεγχο. + +Χρηματοκιβώτιο Multisig +Η καλύτερη διαθέσιμη ασφάλεια στο Bitcoin — απαιτούνται πολλαπλά κλειδιά για να ξοδέψεις, όπως σε ένα πραγματικό χρηματοκιβώτιο. + +Πλήρης κρυπτογράφηση +Πέρα από την κρυπτογράφηση της συσκευής, τα πάντα κρυπτογραφούνται ξανά με τον δικό σου κωδικό πρόσβασης. + +Εύλογη δυνατότητα άρνησης +Όρισε έναν κωδικό-δόλωμα που ανοίγει ψεύτικα πορτοφόλια αν ποτέ αναγκαστείς να ξεκλειδώσεις. + +_____ + +ΙΣΧΥΣ ΚΑΙ ΕΛΕΓΧΟΣ + +Σύγχρονα πορτοφόλια +HD πορτοφόλια με πλήρη υποστήριξη για διευθύνσεις Legacy, SegWit και Taproot. + +Πορτοφόλια υλικού +Σύνδεσε Coldcard, Keystone και άλλους υπογράφοντες PSBT, και κράτα τα νομίσματά σου σε ψυχρή αποθήκευση. + +Πορτοφόλια μόνο για παρακολούθηση +Παρακολούθησε την ψυχρή σου αποθήκευση χωρίς ποτέ να εκθέτεις τα ιδιωτικά σου κλειδιά. + +Τρέξε τον δικό σου κόμβο +Συνδέσου στον δικό σου πλήρη κόμβο Bitcoin μέσω Electrum, και δρομολόγησε τη σύνδεσή σου μέσω Tor. + +Διαχείριση νομισμάτων +Δες, σήμανε και πάγωσε τα νομίσματά σου (UTXO) για να δημιουργείς συναλλαγές ακριβώς όπως θέλεις. + +Ευέλικτες προμήθειες +Όρισε τη δική σου προμήθεια, έως και 1 sat/vByte. Επιτάχυνε κολλημένες συναλλαγές με RBF ή CPFP, ή ακύρωσέ τες. + +_____ + +LIGHTNING NETWORK + +Άδικα φθηνές και αστραπιαία γρήγορες πληρωμές με μηδενική παραμετροποίηση. Πλήρωσε και πληρώσου με διευθύνσεις Lightning και LNURL. + +_____ + +Διαθέσιμο σε iOS, Android και macOS. Πολλαπλά νομίσματα και γλώσσες. Σκοτεινή λειτουργία. Χωρίς λογαριασμούς και χωρίς παρακολούθηση — μόνο Bitcoin. + +BlueWallet — κατέχεις το Bitcoin σου. \ No newline at end of file diff --git a/fastlane/metadata/ios/el/keywords.txt b/fastlane/metadata/ios/el/keywords.txt new file mode 100644 index 00000000000..f30f4bdac66 --- /dev/null +++ b/fastlane/metadata/ios/el/keywords.txt @@ -0,0 +1 @@ +bitcoin,πορτοφόλι,πορτοφόλι bitcoin,blockchain,btc,κρυπτονόμισμα,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/el/name.txt b/fastlane/metadata/ios/el/name.txt new file mode 100644 index 00000000000..0c8270eb190 --- /dev/null +++ b/fastlane/metadata/ios/el/name.txt @@ -0,0 +1 @@ +BlueWallet - Πορτοφόλι Bitcoin diff --git a/fastlane/metadata/ios/el/promotional_text.txt b/fastlane/metadata/ios/el/promotional_text.txt new file mode 100644 index 00000000000..84264e41751 --- /dev/null +++ b/fastlane/metadata/ios/el/promotional_text.txt @@ -0,0 +1,10 @@ +Χαρακτηριστικά + +* Ανοιχτού κώδικα +* Πλήρης κρυπτογράφηση +* Εύλογη άρνηση +* Ευέλικτες προμήθειες +* Replace-By-Fee (RBF) +* SegWit +* Μόνο παρακολούθηση +* Δίκτυο Lightning diff --git a/ios/fastlane/metadata/en-US/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/en-US/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/en-US/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/en-US/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/en-US/description.txt b/fastlane/metadata/ios/en-US/description.txt new file mode 100644 index 00000000000..d90eaa85298 --- /dev/null +++ b/fastlane/metadata/ios/en-US/description.txt @@ -0,0 +1,56 @@ +BlueWallet is a radically simple, powerful and secure Bitcoin wallet. Store, send and receive Bitcoin while staying in full control — your keys, your coins. + +Free and open source, built by Bitcoin users for the community. Transact with anyone in the world and take charge of your money, right from your pocket. Create unlimited wallets for free or import an existing one — it's fast and simple. + +_____ + +SECURITY BY DESIGN + +Open source +MIT licensed — audit it, build it and run it yourself. Made with React Native. + +Your keys, your coins +Private keys (your seed) never leave your device. You are always in control. + +Multisig Vault +The best security available on Bitcoin — multiple keys are required to spend, just like a real vault. + +Full encryption +On top of the device encryption, everything is encrypted again with your own password. + +Plausible deniability +Set a decoy password that opens fake wallets if you are ever forced to unlock. + +_____ + +POWER AND CONTROL + +Modern wallets +HD wallets with full support for Legacy, SegWit and Taproot addresses. + +Hardware wallets +Pair Coldcard, Keystone and other PSBT signers, and keep your coins in cold storage. + +Watch-only wallets +Keep an eye on your cold storage without ever exposing your private keys. + +Run your own node +Connect to your own Bitcoin full node over Electrum, and route your connection through Tor. + +Coin control +See, label and freeze your coins (UTXOs) to build transactions exactly the way you want. + +Flexible fees +Set your own fee, down to 1 sat/vByte. Speed up stuck transactions with RBF or CPFP, or cancel them. + +_____ + +LIGHTNING NETWORK + +Unfairly cheap and blazing fast payments with zero configuration. Pay and get paid with Lightning addresses and LNURL. + +_____ + +Available on iOS, Android and macOS. Multiple currencies and languages. Dark mode. No accounts and no tracking — just Bitcoin. + +BlueWallet — own your Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/en-US/keywords.txt b/fastlane/metadata/ios/en-US/keywords.txt new file mode 100644 index 00000000000..33cad577905 --- /dev/null +++ b/fastlane/metadata/ios/en-US/keywords.txt @@ -0,0 +1 @@ +bitcoin,wallet,bitcoin wallet,blockchain,btc,cryptocurrency,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/en-US/marketing_url.txt b/fastlane/metadata/ios/en-US/marketing_url.txt new file mode 100644 index 00000000000..437112fd4cd --- /dev/null +++ b/fastlane/metadata/ios/en-US/marketing_url.txt @@ -0,0 +1 @@ +https://bluewallet.io diff --git a/ios/fastlane/metadata/en-US/name.txt b/fastlane/metadata/ios/en-US/name.txt similarity index 100% rename from ios/fastlane/metadata/en-US/name.txt rename to fastlane/metadata/ios/en-US/name.txt diff --git a/fastlane/metadata/ios/en-US/privacy_url.txt b/fastlane/metadata/ios/en-US/privacy_url.txt new file mode 100644 index 00000000000..9a0c64f00e6 --- /dev/null +++ b/fastlane/metadata/ios/en-US/privacy_url.txt @@ -0,0 +1 @@ +https://bluewallet.io/privacy.txt diff --git a/fastlane/metadata/ios/en-US/promotional_text.txt b/fastlane/metadata/ios/en-US/promotional_text.txt new file mode 100644 index 00000000000..ed95c2b996c --- /dev/null +++ b/fastlane/metadata/ios/en-US/promotional_text.txt @@ -0,0 +1,10 @@ +Features + +* Open Source +* Full encryption +* Plausible deniability +* Flexible fees +* Replace-By-Fee (RBF) +* SegWit +* Watch-only wallets +* Lightning network diff --git a/fastlane/metadata/ios/en-US/release_notes.txt b/fastlane/metadata/ios/en-US/release_notes.txt new file mode 100644 index 00000000000..fea04268e37 --- /dev/null +++ b/fastlane/metadata/ios/en-US/release_notes.txt @@ -0,0 +1,8 @@ +v8.0.1 +====== +* ADD: Show MAX sendable amount when sending +* ADD: Import Keystone hardware wallets +* ADD: Tap to copy values in transaction details +* ADD: Highlight first and last segments of a Bitcoin address +* FIX: Editing cosigners in multisig vaults +* REF: Prefer BlueWallet Electrum server for faster, more reliable connectivity diff --git a/ios/fastlane/metadata/en-US/subtitle.txt b/fastlane/metadata/ios/en-US/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/en-US/subtitle.txt rename to fastlane/metadata/ios/en-US/subtitle.txt diff --git a/ios/fastlane/metadata/en-US/support_url.txt b/fastlane/metadata/ios/en-US/support_url.txt similarity index 100% rename from ios/fastlane/metadata/en-US/support_url.txt rename to fastlane/metadata/ios/en-US/support_url.txt diff --git a/ios/fastlane/metadata/es-ES/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/es-ES/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/es-ES/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/es-ES/description.txt b/fastlane/metadata/ios/es-ES/description.txt new file mode 100644 index 00000000000..7ee525ccea1 --- /dev/null +++ b/fastlane/metadata/ios/es-ES/description.txt @@ -0,0 +1,56 @@ +BlueWallet es una cartera de Bitcoin radicalmente sencilla, potente y segura. Guarda, envía y recibe Bitcoin manteniendo el control total: tus llaves, tus monedas. + +Gratuita y de código abierto, creada por usuarios de Bitcoin para la comunidad. Opera con cualquier persona del mundo y toma las riendas de tu dinero, directamente desde tu bolsillo. Crea carteras ilimitadas gratis o importa una que ya tengas: es rápido y sencillo. + +_____ + +SEGURIDAD POR DISEÑO + +Código abierto +Con licencia MIT: audítala, compílala y ejecútala tú mismo. Hecha con React Native. + +Tus llaves, tus monedas +Las llaves privadas (tu semilla) nunca salen de tu dispositivo. Siempre tienes el control. + +Bóveda multifirma +La mejor seguridad disponible en Bitcoin: se necesitan varias llaves para gastar, igual que en una bóveda real. + +Cifrado total +Además del cifrado del dispositivo, todo se cifra de nuevo con tu propia contraseña. + +Negación plausible +Configura una contraseña señuelo que abre carteras falsas si alguna vez te obligan a desbloquear. + +_____ + +POTENCIA Y CONTROL + +Carteras modernas +Carteras HD con compatibilidad total con direcciones Legacy, SegWit y Taproot. + +Carteras de hardware +Empareja Coldcard, Keystone y otros firmantes PSBT, y guarda tus monedas en almacenamiento en frío. + +Carteras de solo lectura +Vigila tu almacenamiento en frío sin exponer nunca tus llaves privadas. + +Ejecuta tu propio nodo +Conéctate a tu propio nodo completo de Bitcoin mediante Electrum y enruta tu conexión a través de Tor. + +Control de monedas +Visualiza, etiqueta y congela tus monedas (UTXOs) para construir transacciones exactamente como quieras. + +Comisiones flexibles +Establece tu propia comisión, hasta 1 sat/vByte. Acelera transacciones atascadas con RBF o CPFP, o cancélalas. + +_____ + +LIGHTNING NETWORK + +Pagos increíblemente baratos y velocísimos sin ninguna configuración. Paga y cobra con direcciones Lightning y LNURL. + +_____ + +Disponible en iOS, Android y macOS. Múltiples monedas e idiomas. Modo oscuro. Sin cuentas ni rastreo: solo Bitcoin. + +BlueWallet: tu Bitcoin es tuyo. \ No newline at end of file diff --git a/fastlane/metadata/ios/es-ES/keywords.txt b/fastlane/metadata/ios/es-ES/keywords.txt new file mode 100644 index 00000000000..71d0fd1b4c4 --- /dev/null +++ b/fastlane/metadata/ios/es-ES/keywords.txt @@ -0,0 +1 @@ +bitcoin,cartera,cartera bitcoin,blockchain,btc,criptomoneda,lightning,segwit,multifirma,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/en-US/marketing_url.txt b/fastlane/metadata/ios/es-ES/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/en-US/marketing_url.txt rename to fastlane/metadata/ios/es-ES/marketing_url.txt diff --git a/fastlane/metadata/ios/es-ES/name.txt b/fastlane/metadata/ios/es-ES/name.txt new file mode 100644 index 00000000000..86a3e2cca86 --- /dev/null +++ b/fastlane/metadata/ios/es-ES/name.txt @@ -0,0 +1 @@ +BlueWallet - Cartera Bitcoin diff --git a/ios/fastlane/metadata/en-US/privacy_url.txt b/fastlane/metadata/ios/es-ES/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/en-US/privacy_url.txt rename to fastlane/metadata/ios/es-ES/privacy_url.txt diff --git a/fastlane/metadata/ios/es-ES/promotional_text.txt b/fastlane/metadata/ios/es-ES/promotional_text.txt new file mode 100644 index 00000000000..64f49c3b627 --- /dev/null +++ b/fastlane/metadata/ios/es-ES/promotional_text.txt @@ -0,0 +1,10 @@ +Características + +* Código abierto +* Cifrado total +* Negación plausible +* Comisiones flexibles +* Replace-By-Fee (RBF) +* SegWit +* Carteras de solo lectura +* Red Lightning diff --git a/ios/fastlane/metadata/es-ES/release_notes.txt b/fastlane/metadata/ios/es-ES/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/release_notes.txt rename to fastlane/metadata/ios/es-ES/release_notes.txt diff --git a/ios/fastlane/metadata/es-ES/subtitle.txt b/fastlane/metadata/ios/es-ES/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/subtitle.txt rename to fastlane/metadata/ios/es-ES/subtitle.txt diff --git a/ios/fastlane/metadata/es-ES/support_url.txt b/fastlane/metadata/ios/es-ES/support_url.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/support_url.txt rename to fastlane/metadata/ios/es-ES/support_url.txt diff --git a/ios/fastlane/metadata/es-MX/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/es-MX/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/es-MX/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/es-MX/description.txt b/fastlane/metadata/ios/es-MX/description.txt new file mode 100644 index 00000000000..6bafda1b890 --- /dev/null +++ b/fastlane/metadata/ios/es-MX/description.txt @@ -0,0 +1,56 @@ +BlueWallet es una billetera de Bitcoin radicalmente simple, potente y segura. Guarda, envía y recibe Bitcoin manteniendo el control total: tus claves, tus monedas. + +Gratis y de código abierto, creada por usuarios de Bitcoin para la comunidad. Haz transacciones con cualquier persona del mundo y toma el control de tu dinero, directamente desde tu bolsillo. Crea billeteras ilimitadas gratis o importa una existente: es rápido y sencillo. + +_____ + +SEGURIDAD POR DISEÑO + +Código abierto +Con licencia MIT: audítala, compílala y ejecútala tú mismo. Hecha con React Native. + +Tus claves, tus monedas +Las claves privadas (tu semilla) nunca salen de tu dispositivo. Siempre tienes el control. + +Bóveda Multisig +La mejor seguridad disponible en Bitcoin: se requieren varias claves para gastar, igual que en una bóveda real. + +Cifrado completo +Además del cifrado del dispositivo, todo se cifra de nuevo con tu propia contraseña. + +Negación plausible +Configura una contraseña señuelo que abre billeteras falsas si alguna vez te obligan a desbloquear. + +_____ + +POTENCIA Y CONTROL + +Billeteras modernas +Billeteras HD con soporte completo para direcciones Legacy, SegWit y Taproot. + +Billeteras de hardware +Vincula Coldcard, Keystone y otros firmantes PSBT, y mantén tus monedas en almacenamiento en frío. + +Billeteras de solo lectura +Vigila tu almacenamiento en frío sin exponer nunca tus claves privadas. + +Ejecuta tu propio nodo +Conéctate a tu propio nodo completo de Bitcoin mediante Electrum y enruta tu conexión a través de Tor. + +Control de monedas +Ve, etiqueta y congela tus monedas (UTXOs) para construir transacciones exactamente como quieres. + +Comisiones flexibles +Define tu propia comisión, hasta 1 sat/vByte. Acelera transacciones atascadas con RBF o CPFP, o cancélalas. + +_____ + +LIGHTNING NETWORK + +Pagos increíblemente baratos y ultrarrápidos sin ninguna configuración. Paga y recibe pagos con direcciones Lightning y LNURL. + +_____ + +Disponible en iOS, Android y macOS. Múltiples monedas e idiomas. Modo oscuro. Sin cuentas y sin rastreo: solo Bitcoin. + +BlueWallet: posee tu Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/es-MX/keywords.txt b/fastlane/metadata/ios/es-MX/keywords.txt new file mode 100644 index 00000000000..0f6836c748f --- /dev/null +++ b/fastlane/metadata/ios/es-MX/keywords.txt @@ -0,0 +1 @@ +bitcoin,billetera,billetera bitcoin,blockchain,btc,criptomoneda,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/es-ES/marketing_url.txt b/fastlane/metadata/ios/es-MX/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/marketing_url.txt rename to fastlane/metadata/ios/es-MX/marketing_url.txt diff --git a/ios/fastlane/metadata/es-MX/name.txt b/fastlane/metadata/ios/es-MX/name.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/name.txt rename to fastlane/metadata/ios/es-MX/name.txt diff --git a/ios/fastlane/metadata/es-ES/privacy_url.txt b/fastlane/metadata/ios/es-MX/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/es-ES/privacy_url.txt rename to fastlane/metadata/ios/es-MX/privacy_url.txt diff --git a/fastlane/metadata/ios/es-MX/promotional_text.txt b/fastlane/metadata/ios/es-MX/promotional_text.txt new file mode 100644 index 00000000000..f3b4ceb5db8 --- /dev/null +++ b/fastlane/metadata/ios/es-MX/promotional_text.txt @@ -0,0 +1,10 @@ +Funciones + +* Código abierto +* Cifrado completo +* Negación plausible +* Comisiones flexibles +* Replace-By-Fee (RBF) +* SegWit +* Billeteras de solo lectura +* Lightning diff --git a/ios/fastlane/metadata/es-MX/release_notes.txt b/fastlane/metadata/ios/es-MX/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/release_notes.txt rename to fastlane/metadata/ios/es-MX/release_notes.txt diff --git a/ios/fastlane/metadata/es-MX/subtitle.txt b/fastlane/metadata/ios/es-MX/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/subtitle.txt rename to fastlane/metadata/ios/es-MX/subtitle.txt diff --git a/ios/fastlane/metadata/es-MX/support_url.txt b/fastlane/metadata/ios/es-MX/support_url.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/support_url.txt rename to fastlane/metadata/ios/es-MX/support_url.txt diff --git a/ios/fastlane/metadata/fi/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/fi/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/fi/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/fi/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/fi/description.txt b/fastlane/metadata/ios/fi/description.txt new file mode 100644 index 00000000000..458f9e57dbf --- /dev/null +++ b/fastlane/metadata/ios/fi/description.txt @@ -0,0 +1,56 @@ +BlueWallet on äärimmäisen yksinkertainen, tehokas ja turvallinen Bitcoin-lompakko. Säilytä, lähetä ja vastaanota bitcoineja säilyttäen täyden hallinnan — sinun avaimesi, sinun kolikkosi. + +Ilmainen ja avoimen lähdekoodin sovellus, jonka Bitcoin-käyttäjät ovat rakentaneet yhteisölle. Tee siirtoja kenen tahansa kanssa ympäri maailman ja hallitse rahojasi suoraan taskustasi. Luo rajattomasti lompakoita ilmaiseksi tai tuo olemassa oleva — se on nopeaa ja yksinkertaista. + +_____ + +TURVALLISUUS LÄHTÖKOHTANA + +Avoin lähdekoodi +MIT-lisensoitu — tarkasta, käännä ja aja se itse. Tehty React Nativella. + +Sinun avaimesi, sinun kolikkosi +Yksityiset avaimet (siemenesi) eivät koskaan poistu laitteeltasi. Olet aina itse hallinnassa. + +Multisig-holvi +Paras Bitcoinissa saatavilla oleva turvallisuus — varojen käyttöön tarvitaan useita avaimia, aivan kuten oikeassa holvissa. + +Täysi salaus +Laitteen salauksen lisäksi kaikki salataan uudelleen omalla salasanallasi. + +Uskottava kiistettävyys +Aseta houkutussalasana, joka avaa valelompakot, jos sinut joskus pakotetaan avaamaan sovellus. + +_____ + +TEHO JA HALLINTA + +Modernit lompakot +HD-lompakot, joissa on täysi tuki Legacy-, SegWit- ja Taproot-osoitteille. + +Laitelompakot +Yhdistä Coldcard, Keystone ja muut PSBT-allekirjoittajat ja säilytä kolikkosi kylmäsäilytyksessä. + +Katselulompakot +Pidä silmällä kylmäsäilytystäsi paljastamatta koskaan yksityisiä avaimiasi. + +Aja oma solmusi +Yhdistä omaan Bitcoin-täyssolmuusi Electrumin kautta ja reititä yhteytesi Torin läpi. + +Kolikoiden hallinta +Katsele, merkitse ja jäädytä kolikoitasi (UTXO:t) rakentaaksesi siirtotapahtumat juuri haluamallasi tavalla. + +Joustavat siirtomaksut +Aseta oma siirtomaksusi aina 1 sat/vByteen asti. Nopeuta jumiutuneita siirtotapahtumia RBF:llä tai CPFP:llä, tai peru ne. + +_____ + +LIGHTNING-VERKKO + +Epäreilun halvat ja salamannopeat maksut ilman lainkaan määrityksiä. Maksa ja vastaanota maksuja Lightning-osoitteilla ja LNURL:lla. + +_____ + +Saatavilla iOS:lle, Androidille ja macOS:lle. Useita valuuttoja ja kieliä. Tumma tila. Ei tilejä eikä seurantaa — pelkkää Bitcoinia. + +BlueWallet — omista Bitcoinisi. \ No newline at end of file diff --git a/fastlane/metadata/ios/fi/keywords.txt b/fastlane/metadata/ios/fi/keywords.txt new file mode 100644 index 00000000000..261ec09c5ee --- /dev/null +++ b/fastlane/metadata/ios/fi/keywords.txt @@ -0,0 +1 @@ +bitcoin,lompakko,bitcoin-lompakko,lohkoketju,btc,kryptovaluutta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/es-MX/marketing_url.txt b/fastlane/metadata/ios/fi/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/marketing_url.txt rename to fastlane/metadata/ios/fi/marketing_url.txt diff --git a/fastlane/metadata/ios/fi/name.txt b/fastlane/metadata/ios/fi/name.txt new file mode 100644 index 00000000000..6ad6bd9eb96 --- /dev/null +++ b/fastlane/metadata/ios/fi/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin-lompakko diff --git a/ios/fastlane/metadata/es-MX/privacy_url.txt b/fastlane/metadata/ios/fi/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/es-MX/privacy_url.txt rename to fastlane/metadata/ios/fi/privacy_url.txt diff --git a/fastlane/metadata/ios/fi/promotional_text.txt b/fastlane/metadata/ios/fi/promotional_text.txt new file mode 100644 index 00000000000..86013bcba7b --- /dev/null +++ b/fastlane/metadata/ios/fi/promotional_text.txt @@ -0,0 +1,10 @@ +Ominaisuudet + +* Avoin lähdekoodi +* Täysi salaus +* Uskottava kiistettävyys +* Joustavat maksut +* Replace-By-Fee (RBF) +* SegWit +* Katselulompakot +* Lightning-verkko diff --git a/ios/fastlane/metadata/fi/release_notes.txt b/fastlane/metadata/ios/fi/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/fi/release_notes.txt rename to fastlane/metadata/ios/fi/release_notes.txt diff --git a/ios/fastlane/metadata/fi/subtitle.txt b/fastlane/metadata/ios/fi/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/fi/subtitle.txt rename to fastlane/metadata/ios/fi/subtitle.txt diff --git a/ios/fastlane/metadata/fi/support_url.txt b/fastlane/metadata/ios/fi/support_url.txt similarity index 100% rename from ios/fastlane/metadata/fi/support_url.txt rename to fastlane/metadata/ios/fi/support_url.txt diff --git a/fastlane/metadata/ios/fr-CA/description.txt b/fastlane/metadata/ios/fr-CA/description.txt new file mode 100644 index 00000000000..139e4c498ad --- /dev/null +++ b/fastlane/metadata/ios/fr-CA/description.txt @@ -0,0 +1,56 @@ +BlueWallet est un portefeuille Bitcoin radicalement simple, puissant et sécuritaire. Stockez, envoyez et recevez des Bitcoin tout en gardant le plein contrôle — vos clés, vos bitcoins. + +Gratuit et à code ouvert, conçu par des utilisateurs de Bitcoin pour la communauté. Effectuez des transactions avec n'importe qui dans le monde et prenez le contrôle de votre argent, directement depuis votre poche. Créez gratuitement un nombre illimité de portefeuilles ou importez-en un existant — c'est rapide et simple. + +_____ + +LA SÉCURITÉ DÈS LA CONCEPTION + +Code ouvert +Sous licence MIT — auditez-le, compilez-le et exécutez-le vous-même. Conçu avec React Native. + +Vos clés, vos bitcoins +Les clés privées (votre phrase de récupération) ne quittent jamais votre appareil. Vous gardez toujours le contrôle. + +Coffre-fort multisignature +La meilleure sécurité offerte sur Bitcoin — plusieurs clés sont requises pour dépenser, comme un véritable coffre-fort. + +Chiffrement complet +En plus du chiffrement de l'appareil, tout est chiffré de nouveau avec votre propre mot de passe. + +Déni plausible +Définissez un mot de passe leurre qui ouvre de faux portefeuilles si l'on vous force un jour à déverrouiller. + +_____ + +PUISSANCE ET CONTRÔLE + +Portefeuilles modernes +Portefeuilles HD avec prise en charge complète des adresses Legacy, SegWit et Taproot. + +Portefeuilles matériels +Jumelez Coldcard, Keystone et d'autres signataires PSBT, et gardez vos bitcoins en stockage à froid. + +Portefeuilles spectateurs +Gardez un œil sur votre stockage à froid sans jamais exposer vos clés privées. + +Exécutez votre propre nœud +Connectez-vous à votre propre nœud Bitcoin complet via Electrum, et acheminez votre connexion à travers Tor. + +Contrôle des pièces +Visualisez, étiquetez et gelez vos pièces (UTXO) pour bâtir vos transactions exactement comme vous le souhaitez. + +Frais flexibles +Définissez vos propres frais, jusqu'à 1 sat/vByte. Accélérez les transactions bloquées avec RBF ou CPFP, ou annulez-les. + +_____ + +RÉSEAU LIGHTNING + +Des paiements incroyablement peu coûteux et ultra rapides, sans aucune configuration. Payez et soyez payé avec des adresses Lightning et LNURL. + +_____ + +Offert sur iOS, Android et macOS. Plusieurs devises et langues. Mode sombre. Aucun compte et aucun pistage — seulement du Bitcoin. + +BlueWallet — possédez votre Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/fr-CA/keywords.txt b/fastlane/metadata/ios/fr-CA/keywords.txt new file mode 100644 index 00000000000..7b8992d9b29 --- /dev/null +++ b/fastlane/metadata/ios/fr-CA/keywords.txt @@ -0,0 +1 @@ +bitcoin,portefeuille,portefeuille bitcoin,blockchain,crypto,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/fr-CA/name.txt b/fastlane/metadata/ios/fr-CA/name.txt new file mode 100644 index 00000000000..453c3927bb0 --- /dev/null +++ b/fastlane/metadata/ios/fr-CA/name.txt @@ -0,0 +1 @@ +BlueWallet - Portefeuille BTC diff --git a/fastlane/metadata/ios/fr-CA/promotional_text.txt b/fastlane/metadata/ios/fr-CA/promotional_text.txt new file mode 100644 index 00000000000..4f4400bcf9f --- /dev/null +++ b/fastlane/metadata/ios/fr-CA/promotional_text.txt @@ -0,0 +1,10 @@ +Fonctionnalités + +* Code ouvert +* Chiffrement complet +* Déni plausible +* Frais flexibles +* Replace-By-Fee (RBF) +* SegWit +* Portefeuilles spectateurs +* Réseau Lightning diff --git a/ios/fastlane/metadata/fr-FR/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/fr-FR/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/fr-FR/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/fr-FR/description.txt b/fastlane/metadata/ios/fr-FR/description.txt new file mode 100644 index 00000000000..820ac9eb3dd --- /dev/null +++ b/fastlane/metadata/ios/fr-FR/description.txt @@ -0,0 +1,56 @@ +BlueWallet est un portefeuille Bitcoin radicalement simple, puissant et sécurisé. Stockez, envoyez et recevez des bitcoins tout en gardant le contrôle total — vos clés, vos bitcoins. + +Gratuit et open source, conçu par des utilisateurs de Bitcoin pour la communauté. Effectuez des transactions avec n'importe qui dans le monde et prenez le contrôle de votre argent, directement depuis votre poche. Créez un nombre illimité de portefeuilles gratuitement ou importez-en un existant — c'est rapide et simple. + +_____ + +SÉCURITÉ DÈS LA CONCEPTION + +Open source +Sous licence MIT — auditez-le, compilez-le et exécutez-le vous-même. Réalisé avec React Native. + +Vos clés, vos bitcoins +Les clés privées (votre graine) ne quittent jamais votre appareil. Vous gardez toujours le contrôle. + +Coffre-fort multisig +La meilleure sécurité disponible sur Bitcoin — plusieurs clés sont nécessaires pour dépenser, comme un véritable coffre-fort. + +Chiffrement complet +En plus du chiffrement de l'appareil, tout est à nouveau chiffré avec votre propre mot de passe. + +Déni plausible +Définissez un mot de passe leurre qui ouvre de faux portefeuilles si vous êtes un jour contraint de déverrouiller. + +_____ + +PUISSANCE ET CONTRÔLE + +Portefeuilles modernes +Portefeuilles HD avec prise en charge complète des adresses Legacy, SegWit et Taproot. + +Portefeuilles matériels +Associez Coldcard, Keystone et d'autres signataires PSBT, et conservez vos bitcoins en stockage à froid. + +Portefeuilles en lecture seule +Gardez un œil sur votre stockage à froid sans jamais exposer vos clés privées. + +Exécutez votre propre nœud +Connectez-vous à votre propre nœud Bitcoin complet via Electrum, et faites passer votre connexion par Tor. + +Contrôle des pièces +Visualisez, étiquetez et gelez vos pièces (UTXO) pour construire des transactions exactement comme vous le souhaitez. + +Frais flexibles +Définissez vos propres frais, jusqu'à 1 sat/vByte. Accélérez les transactions bloquées avec RBF ou CPFP, ou annulez-les. + +_____ + +RÉSEAU LIGHTNING + +Des paiements incroyablement bon marché et ultra-rapides, sans aucune configuration. Payez et soyez payé avec les adresses Lightning et LNURL. + +_____ + +Disponible sur iOS, Android et macOS. Plusieurs devises et langues. Mode sombre. Aucun compte ni suivi — juste Bitcoin. + +BlueWallet — possédez vos bitcoins. \ No newline at end of file diff --git a/fastlane/metadata/ios/fr-FR/keywords.txt b/fastlane/metadata/ios/fr-FR/keywords.txt new file mode 100644 index 00000000000..e417e717e05 --- /dev/null +++ b/fastlane/metadata/ios/fr-FR/keywords.txt @@ -0,0 +1 @@ +bitcoin,portefeuille,portefeuille bitcoin,blockchain,btc,crypto,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/fi/marketing_url.txt b/fastlane/metadata/ios/fr-FR/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/fi/marketing_url.txt rename to fastlane/metadata/ios/fr-FR/marketing_url.txt diff --git a/fastlane/metadata/ios/fr-FR/name.txt b/fastlane/metadata/ios/fr-FR/name.txt new file mode 100644 index 00000000000..453c3927bb0 --- /dev/null +++ b/fastlane/metadata/ios/fr-FR/name.txt @@ -0,0 +1 @@ +BlueWallet - Portefeuille BTC diff --git a/ios/fastlane/metadata/fi/privacy_url.txt b/fastlane/metadata/ios/fr-FR/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/fi/privacy_url.txt rename to fastlane/metadata/ios/fr-FR/privacy_url.txt diff --git a/fastlane/metadata/ios/fr-FR/promotional_text.txt b/fastlane/metadata/ios/fr-FR/promotional_text.txt new file mode 100644 index 00000000000..24826bf1287 --- /dev/null +++ b/fastlane/metadata/ios/fr-FR/promotional_text.txt @@ -0,0 +1,10 @@ +Fonctionnalités + +* Open Source +* Chiffrement complet +* Déni plausible +* Frais flexibles +* Replace-By-Fee (RBF) +* SegWit +* Portefeuilles lecture seule +* Réseau Lightning diff --git a/ios/fastlane/metadata/fr-FR/release_notes.txt b/fastlane/metadata/ios/fr-FR/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/release_notes.txt rename to fastlane/metadata/ios/fr-FR/release_notes.txt diff --git a/ios/fastlane/metadata/fr-FR/subtitle.txt b/fastlane/metadata/ios/fr-FR/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/subtitle.txt rename to fastlane/metadata/ios/fr-FR/subtitle.txt diff --git a/ios/fastlane/metadata/fr-FR/support_url.txt b/fastlane/metadata/ios/fr-FR/support_url.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/support_url.txt rename to fastlane/metadata/ios/fr-FR/support_url.txt diff --git a/ios/fastlane/metadata/he/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/he/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/he/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/he/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/he/description.txt b/fastlane/metadata/ios/he/description.txt new file mode 100644 index 00000000000..ff0bed8cb34 --- /dev/null +++ b/fastlane/metadata/ios/he/description.txt @@ -0,0 +1,56 @@ +BlueWallet הוא ארנק ביטקוין פשוט להפליא, עוצמתי ומאובטח. אחסנו, שלחו וקבלו ביטקוין תוך שמירה על שליטה מלאה — המפתחות שלכם, המטבעות שלכם. + +חינמי וקוד פתוח, נבנה על ידי משתמשי ביטקוין למען הקהילה. בצעו עסקאות עם כל אדם בעולם וקחו שליטה על הכסף שלכם, ישירות מהכיס. צרו ארנקים ללא הגבלה בחינם או ייבאו ארנק קיים — מהיר ופשוט. + +_____ + +אבטחה מתוכננת מהיסוד + +קוד פתוח +מורשה ברישיון MIT — בדקו, בנו והריצו בעצמכם. נבנה עם React Native. + +המפתחות שלכם, המטבעות שלכם +המפתחות הפרטיים (מילות השחזור שלכם) לעולם אינם עוזבים את המכשיר. אתם תמיד בשליטה. + +כספת רבת-חתימות +האבטחה הטובה ביותר הזמינה בביטקוין — נדרשים מספר מפתחות כדי להוציא, בדיוק כמו כספת אמיתית. + +הצפנה מלאה +מעבר להצפנת המכשיר, הכול מוצפן שוב עם הסיסמה האישית שלכם. + +יכולת הכחשה סבירה +הגדירו סיסמת הסחה שפותחת ארנקים מזויפים אם אי פעם תיאלצו לפתוח את הארנק. + +_____ + +עוצמה ושליטה + +ארנקים מודרניים +ארנקי HD עם תמיכה מלאה בכתובות Legacy, SegWit ו-Taproot. + +ארנקי חומרה +צמדו את Coldcard, Keystone וחותמי PSBT אחרים, ושמרו את המטבעות שלכם באחסון קר. + +ארנקים לצפייה בלבד +עקבו אחר האחסון הקר שלכם מבלי לחשוף לעולם את המפתחות הפרטיים. + +הריצו צומת משלכם +התחברו לצומת ביטקוין מלא משלכם דרך Electrum, ונתבו את החיבור שלכם דרך Tor. + +שליטת מטבעות +צפו, תייגו והקפיאו את המטבעות שלכם (UTXO) כדי לבנות עסקאות בדיוק כפי שאתם רוצים. + +עמלות גמישות +קבעו את העמלה שלכם, עד 1 sat/vByte. האיצו עסקאות תקועות עם RBF או CPFP, או בטלו אותן. + +_____ + +רשת Lightning + +תשלומים זולים באופן לא הוגן ומהירים במיוחד ללא הגדרות. שלמו וקבלו תשלום עם כתובות Lightning ו-LNURL. + +_____ + +זמין ב-iOS, ב-Android וב-macOS. מטבעות ושפות רבים. מצב כהה. ללא חשבונות וללא מעקב — רק ביטקוין. + +BlueWallet — הביטקוין שלכם, בבעלותכם. \ No newline at end of file diff --git a/fastlane/metadata/ios/he/keywords.txt b/fastlane/metadata/ios/he/keywords.txt new file mode 100644 index 00000000000..bb84a413b5f --- /dev/null +++ b/fastlane/metadata/ios/he/keywords.txt @@ -0,0 +1 @@ +ביטקוין,ארנק,ארנק ביטקוין,בלוקצ'יין,btc,מטבע קריפטו,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/fr-FR/marketing_url.txt b/fastlane/metadata/ios/he/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/marketing_url.txt rename to fastlane/metadata/ios/he/marketing_url.txt diff --git a/ios/fastlane/metadata/he/name.txt b/fastlane/metadata/ios/he/name.txt similarity index 100% rename from ios/fastlane/metadata/he/name.txt rename to fastlane/metadata/ios/he/name.txt diff --git a/ios/fastlane/metadata/fr-FR/privacy_url.txt b/fastlane/metadata/ios/he/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/fr-FR/privacy_url.txt rename to fastlane/metadata/ios/he/privacy_url.txt diff --git a/fastlane/metadata/ios/he/promotional_text.txt b/fastlane/metadata/ios/he/promotional_text.txt new file mode 100644 index 00000000000..cf5216b87f7 --- /dev/null +++ b/fastlane/metadata/ios/he/promotional_text.txt @@ -0,0 +1,10 @@ +תכונות + +* קוד פתוח +* הצפנה מלאה +* הכחשה סבירה +* עמלות גמישות +* Replace-By-Fee (RBF) +* SegWit +* ארנקי צפייה בלבד +* רשת Lightning diff --git a/ios/fastlane/metadata/he/release_notes.txt b/fastlane/metadata/ios/he/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/he/release_notes.txt rename to fastlane/metadata/ios/he/release_notes.txt diff --git a/ios/fastlane/metadata/he/subtitle.txt b/fastlane/metadata/ios/he/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/he/subtitle.txt rename to fastlane/metadata/ios/he/subtitle.txt diff --git a/ios/fastlane/metadata/he/support_url.txt b/fastlane/metadata/ios/he/support_url.txt similarity index 100% rename from ios/fastlane/metadata/he/support_url.txt rename to fastlane/metadata/ios/he/support_url.txt diff --git a/ios/fastlane/metadata/hu/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/hu/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/hu/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/hu/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/hu/description.txt b/fastlane/metadata/ios/hu/description.txt new file mode 100644 index 00000000000..7923d20fb78 --- /dev/null +++ b/fastlane/metadata/ios/hu/description.txt @@ -0,0 +1,56 @@ +A BlueWallet egy rendkívül egyszerű, hatékony és biztonságos Bitcoin tárca. Tárold, küldd és fogadd a Bitcoint úgy, hogy közben végig te irányítasz — a te kulcsaid, a te érméid. + +Ingyenes és nyílt forráskódú, Bitcoin felhasználók készítették a közösségnek. Bonyolíts tranzakciókat bárkivel a világon, és vedd kézbe a pénzed, egyenesen a zsebedből. Hozz létre korlátlan számú tárcát ingyen, vagy importálj egy meglévőt — gyors és egyszerű. + +_____ + +TERVEZETT BIZTONSÁG + +Nyílt forráskód +MIT licenc alatt — auditáld, fordítsd és futtasd te magad. React Native technológiával készült. + +A te kulcsaid, a te érméid +A privát kulcsok (a mag-kifejezésed) soha nem hagyják el az eszközöd. Mindig te irányítasz. + +Multisig széf +A Bitcoinon elérhető legjobb biztonság — több kulcs kell a költéshez, akárcsak egy igazi széfnél. + +Teljes titkosítás +Az eszköz titkosításán felül minden újra titkosításra kerül a saját jelszavaddal. + +Elfogadható tagadhatóság +Állíts be egy csali jelszót, amely hamis tárcákat nyit meg, ha valaha is feloldásra kényszerítenének. + +_____ + +ERŐ ÉS IRÁNYÍTÁS + +Modern tárcák +HD tárcák, teljes támogatással a Legacy, SegWit és Taproot címekhez. + +Hardveres tárcák +Párosíts Coldcard, Keystone és más PSBT aláírókat, és tartsd az érméid hideg tárolóban. + +Csak megtekintésre szolgáló tárcák +Tartsd szemmel a hideg tárolód anélkül, hogy valaha is felfednéd a privát kulcsaid. + +Futtasd a saját node-od +Csatlakozz a saját Bitcoin teljes node-odhoz Electrumon keresztül, és vezesd a kapcsolatod a Tor hálózaton át. + +Érmekezelés +Nézd meg, címkézd és fagyaszd be az érméid (UTXO-kat), hogy pontosan úgy állítsd össze a tranzakcióidat, ahogy szeretnéd. + +Rugalmas díjak +Állítsd be a saját díjad, akár 1 sat/vByte-ig. Gyorsítsd fel a beragadt tranzakciókat RBF vagy CPFP segítségével, vagy mondd vissza őket. + +_____ + +LIGHTNING HÁLÓZAT + +Hihetetlenül olcsó és villámgyors fizetések, nulla konfigurációval. Fizess és fogadj fizetéseket Lightning címekkel és LNURL-lel. + +_____ + +Elérhető iOS-en, Androidon és macOS-en. Több pénznem és nyelv. Sötét mód. Nincsenek fiókok és nincs nyomkövetés — csak Bitcoin. + +BlueWallet — birtokold a Bitcoinodat. \ No newline at end of file diff --git a/fastlane/metadata/ios/hu/keywords.txt b/fastlane/metadata/ios/hu/keywords.txt new file mode 100644 index 00000000000..38d952dcb02 --- /dev/null +++ b/fastlane/metadata/ios/hu/keywords.txt @@ -0,0 +1 @@ +bitcoin,tárca,bitcoin tárca,blokklánc,btc,kriptovaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/he/marketing_url.txt b/fastlane/metadata/ios/hu/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/he/marketing_url.txt rename to fastlane/metadata/ios/hu/marketing_url.txt diff --git a/fastlane/metadata/ios/hu/name.txt b/fastlane/metadata/ios/hu/name.txt new file mode 100644 index 00000000000..bfa777d09ca --- /dev/null +++ b/fastlane/metadata/ios/hu/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin tárca diff --git a/ios/fastlane/metadata/he/privacy_url.txt b/fastlane/metadata/ios/hu/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/he/privacy_url.txt rename to fastlane/metadata/ios/hu/privacy_url.txt diff --git a/fastlane/metadata/ios/hu/promotional_text.txt b/fastlane/metadata/ios/hu/promotional_text.txt new file mode 100644 index 00000000000..8740f1aecef --- /dev/null +++ b/fastlane/metadata/ios/hu/promotional_text.txt @@ -0,0 +1,10 @@ +Funkciók + +* Nyílt forráskód +* Teljes titkosítás +* Elfogadható tagadhatóság +* Rugalmas díjak +* Replace-By-Fee (RBF) +* SegWit +* Csak megtekintésre +* Lightning hálózat diff --git a/ios/fastlane/metadata/hu/release_notes.txt b/fastlane/metadata/ios/hu/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/hu/release_notes.txt rename to fastlane/metadata/ios/hu/release_notes.txt diff --git a/ios/fastlane/metadata/hu/subtitle.txt b/fastlane/metadata/ios/hu/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/hu/subtitle.txt rename to fastlane/metadata/ios/hu/subtitle.txt diff --git a/ios/fastlane/metadata/hu/support_url.txt b/fastlane/metadata/ios/hu/support_url.txt similarity index 100% rename from ios/fastlane/metadata/hu/support_url.txt rename to fastlane/metadata/ios/hu/support_url.txt diff --git a/fastlane/metadata/ios/id/description.txt b/fastlane/metadata/ios/id/description.txt new file mode 100644 index 00000000000..5e59614a997 --- /dev/null +++ b/fastlane/metadata/ios/id/description.txt @@ -0,0 +1,56 @@ +BlueWallet adalah dompet Bitcoin yang sangat sederhana, andal, dan aman. Simpan, kirim, dan terima Bitcoin sambil tetap memegang kendali penuh — kunci Anda, koin Anda. + +Gratis dan open source, dibuat oleh pengguna Bitcoin untuk komunitas. Bertransaksilah dengan siapa pun di dunia dan kendalikan uang Anda, langsung dari saku Anda. Buat dompet tanpa batas secara gratis atau impor dompet yang sudah ada — cepat dan sederhana. + +_____ + +KEAMANAN BERDASARKAN DESAIN + +Open source +Berlisensi MIT — audit, bangun, dan jalankan sendiri. Dibuat dengan React Native. + +Kunci Anda, koin Anda +Kunci privat (frasa pemulihan Anda) tidak pernah meninggalkan perangkat Anda. Anda selalu memegang kendali. + +Brankas Multisig +Keamanan terbaik yang tersedia di Bitcoin — beberapa kunci diperlukan untuk membelanjakan, seperti brankas sungguhan. + +Enkripsi penuh +Di atas enkripsi perangkat, semuanya dienkripsi lagi dengan kata sandi Anda sendiri. + +Penyangkalan yang masuk akal +Atur kata sandi umpan yang membuka dompet palsu jika Anda dipaksa untuk membuka kunci. + +_____ + +KEKUATAN DAN KENDALI + +Dompet modern +Dompet HD dengan dukungan penuh untuk alamat Legacy, SegWit, dan Taproot. + +Dompet perangkat keras +Pasangkan Coldcard, Keystone, dan penanda tangan PSBT lainnya, dan simpan koin Anda di penyimpanan dingin. + +Dompet hanya-lihat +Pantau penyimpanan dingin Anda tanpa pernah mengekspos kunci privat Anda. + +Jalankan node Anda sendiri +Hubungkan ke full node Bitcoin Anda sendiri melalui Electrum, dan arahkan koneksi Anda melalui Tor. + +Kontrol koin +Lihat, beri label, dan bekukan koin Anda (UTXO) untuk membangun transaksi persis seperti yang Anda inginkan. + +Biaya fleksibel +Tentukan biaya Anda sendiri, hingga 1 sat/vByte. Percepat transaksi yang macet dengan RBF atau CPFP, atau batalkan. + +_____ + +LIGHTNING NETWORK + +Pembayaran yang sangat murah dan sangat cepat tanpa konfigurasi. Bayar dan terima pembayaran dengan alamat Lightning dan LNURL. + +_____ + +Tersedia di iOS, Android, dan macOS. Beragam mata uang dan bahasa. Mode gelap. Tanpa akun dan tanpa pelacakan — hanya Bitcoin. + +BlueWallet — miliki Bitcoin Anda. \ No newline at end of file diff --git a/fastlane/metadata/ios/id/keywords.txt b/fastlane/metadata/ios/id/keywords.txt new file mode 100644 index 00000000000..62a3edce42d --- /dev/null +++ b/fastlane/metadata/ios/id/keywords.txt @@ -0,0 +1 @@ +bitcoin,dompet,dompet bitcoin,blockchain,btc,mata uang kripto,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/id/name.txt b/fastlane/metadata/ios/id/name.txt new file mode 100644 index 00000000000..a4225e74ac8 --- /dev/null +++ b/fastlane/metadata/ios/id/name.txt @@ -0,0 +1 @@ +BlueWallet - Dompet Bitcoin diff --git a/fastlane/metadata/ios/id/promotional_text.txt b/fastlane/metadata/ios/id/promotional_text.txt new file mode 100644 index 00000000000..79b1153756f --- /dev/null +++ b/fastlane/metadata/ios/id/promotional_text.txt @@ -0,0 +1,10 @@ +Fitur + +* Open Source +* Enkripsi penuh +* Penyangkalan yang masuk akal +* Biaya fleksibel +* Replace-By-Fee (RBF) +* SegWit +* Dompet hanya-lihat +* Lightning network diff --git a/ios/fastlane/metadata/it/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/it/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/it/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/it/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/it/description.txt b/fastlane/metadata/ios/it/description.txt new file mode 100644 index 00000000000..ae043704d36 --- /dev/null +++ b/fastlane/metadata/ios/it/description.txt @@ -0,0 +1,56 @@ +BlueWallet è un portafoglio Bitcoin radicalmente semplice, potente e sicuro. Conserva, invia e ricevi Bitcoin mantenendo il pieno controllo — le tue chiavi, le tue monete. + +Gratuito e open source, realizzato da utenti Bitcoin per la comunità. Effettua transazioni con chiunque nel mondo e prendi il controllo del tuo denaro, direttamente dal palmo della mano. Crea portafogli illimitati gratuitamente o importane uno esistente — è veloce e semplice. + +_____ + +SICUREZZA FIN DALLA PROGETTAZIONE + +Open source +Licenza MIT — verificalo, compilalo ed eseguilo per conto tuo. Realizzato con React Native. + +Le tue chiavi, le tue monete +Le chiavi private (il tuo seed) non lasciano mai il tuo dispositivo. Hai sempre il controllo. + +Cassaforte multisig +La migliore sicurezza disponibile su Bitcoin — per spendere servono più chiavi, proprio come una vera cassaforte. + +Crittografia completa +Oltre alla crittografia del dispositivo, tutto viene cifrato di nuovo con la tua password personale. + +Negazione plausibile +Imposta una password esca che apre portafogli falsi se sei mai costretto a sbloccare. + +_____ + +POTENZA E CONTROLLO + +Portafogli moderni +Portafogli HD con pieno supporto per indirizzi Legacy, SegWit e Taproot. + +Portafogli hardware +Abbina Coldcard, Keystone e altri firmatari PSBT, e tieni le tue monete in cold storage. + +Portafogli in sola lettura +Tieni d'occhio il tuo cold storage senza mai esporre le tue chiavi private. + +Gestisci il tuo nodo +Connettiti al tuo full node Bitcoin tramite Electrum e instrada la connessione attraverso Tor. + +Controllo delle monete +Visualizza, etichetta e congela le tue monete (UTXO) per costruire transazioni esattamente come vuoi. + +Commissioni flessibili +Imposta la tua commissione, fino a 1 sat/vByte. Velocizza le transazioni bloccate con RBF o CPFP, oppure annullale. + +_____ + +LIGHTNING NETWORK + +Pagamenti incredibilmente economici e velocissimi senza alcuna configurazione. Paga e ricevi pagamenti con indirizzi Lightning e LNURL. + +_____ + +Disponibile su iOS, Android e macOS. Più valute e lingue. Modalità scura. Nessun account e nessun tracciamento — solo Bitcoin. + +BlueWallet — possiedi i tuoi Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/it/keywords.txt b/fastlane/metadata/ios/it/keywords.txt new file mode 100644 index 00000000000..9526de574fc --- /dev/null +++ b/fastlane/metadata/ios/it/keywords.txt @@ -0,0 +1 @@ +bitcoin,portafoglio,portafoglio bitcoin,blockchain,btc,crypto,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/hu/marketing_url.txt b/fastlane/metadata/ios/it/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/hu/marketing_url.txt rename to fastlane/metadata/ios/it/marketing_url.txt diff --git a/ios/fastlane/metadata/no/name.txt b/fastlane/metadata/ios/it/name.txt similarity index 100% rename from ios/fastlane/metadata/no/name.txt rename to fastlane/metadata/ios/it/name.txt diff --git a/ios/fastlane/metadata/hu/privacy_url.txt b/fastlane/metadata/ios/it/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/hu/privacy_url.txt rename to fastlane/metadata/ios/it/privacy_url.txt diff --git a/fastlane/metadata/ios/it/promotional_text.txt b/fastlane/metadata/ios/it/promotional_text.txt new file mode 100644 index 00000000000..6939e73f86e --- /dev/null +++ b/fastlane/metadata/ios/it/promotional_text.txt @@ -0,0 +1,10 @@ +Funzionalità + +* Open Source +* Crittografia completa +* Negazione plausibile +* Commissioni flessibili +* Replace-By-Fee (RBF) +* SegWit +* Sola lettura +* Lightning network diff --git a/ios/fastlane/metadata/it/release_notes.txt b/fastlane/metadata/ios/it/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/it/release_notes.txt rename to fastlane/metadata/ios/it/release_notes.txt diff --git a/ios/fastlane/metadata/it/subtitle.txt b/fastlane/metadata/ios/it/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/it/subtitle.txt rename to fastlane/metadata/ios/it/subtitle.txt diff --git a/ios/fastlane/metadata/it/support_url.txt b/fastlane/metadata/ios/it/support_url.txt similarity index 100% rename from ios/fastlane/metadata/it/support_url.txt rename to fastlane/metadata/ios/it/support_url.txt diff --git a/fastlane/metadata/ios/ja/description.txt b/fastlane/metadata/ios/ja/description.txt new file mode 100644 index 00000000000..a40869e5a79 --- /dev/null +++ b/fastlane/metadata/ios/ja/description.txt @@ -0,0 +1,56 @@ +BlueWalletは、徹底的にシンプルで強力、そして安全なBitcoinウォレットです。完全にコントロールを保ったまま、Bitcoinを保管・送金・受け取りできます。あなたの鍵、あなたのコイン。 + +無料かつオープンソースで、Bitcoinユーザーがコミュニティのために作りました。世界中の誰とでも取引でき、ポケットの中から自分のお金を管理できます。無制限のウォレットを無料で作成するか、既存のウォレットをインポートできます。高速でシンプルです。 + +_____ + +設計から考え抜かれたセキュリティ + +オープンソース +MITライセンス。自分で監査し、ビルドし、実行できます。React Nativeで作られています。 + +あなたの鍵、あなたのコイン +秘密鍵(シード)が端末から外に出ることはありません。常にあなたがコントロールします。 + +マルチシグ金庫 +Bitcoinで利用できる最高のセキュリティ。本物の金庫のように、支払いには複数の鍵が必要です。 + +完全な暗号化 +端末の暗号化に加えて、すべてがあなた自身のパスワードでもう一度暗号化されます。 + +隠匿設定 +ロック解除を強要された場合に偽のウォレットを開く、おとりパスワードを設定できます。 + +_____ + +パワーとコントロール + +モダンなウォレット +Legacy、SegWit、Taprootアドレスにフル対応したHDウォレット。 + +ハードウェアウォレット +Coldcard、KeystoneなどのPSBT署名デバイスとペアリングし、コインをコールドストレージに保管できます。 + +閲覧専用ウォレット +秘密鍵を一切公開することなく、コールドストレージを見守れます。 + +自分のノードを運用 +Electrum経由で自分のBitcoinフルノードに接続し、接続をTor経由でルーティングできます。 + +コイン管理 +コイン(UTXO)を確認・ラベル付け・フリーズして、思いどおりにトランザクションを構築できます。 + +柔軟な手数料 +1 sat/vByteまで自分で手数料を設定できます。滞ったトランザクションをRBFやCPFPで高速化したり、キャンセルしたりできます。 + +_____ + +LIGHTNINGネットワーク + +設定不要で、ずるいほど安く、超高速な支払い。LightningアドレスやLNURLで支払い・受け取りができます。 + +_____ + +iOS、Android、macOSで利用可能。複数の通貨と言語に対応。ダークモード。アカウント不要・トラッキングなし。あるのはBitcoinだけ。 + +BlueWallet — あなたのBitcoinを、あなたのものに。 \ No newline at end of file diff --git a/fastlane/metadata/ios/ja/keywords.txt b/fastlane/metadata/ios/ja/keywords.txt new file mode 100644 index 00000000000..0a0b468c0c3 --- /dev/null +++ b/fastlane/metadata/ios/ja/keywords.txt @@ -0,0 +1 @@ +ビットコイン,ウォレット,ビットコインウォレット,ブロックチェーン,btc,暗号資産,lightning,segwit,マルチシグ,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/ja/name.txt b/fastlane/metadata/ios/ja/name.txt new file mode 100644 index 00000000000..4e666af3eb8 --- /dev/null +++ b/fastlane/metadata/ios/ja/name.txt @@ -0,0 +1 @@ +BlueWallet - ビットコインウォレット diff --git a/fastlane/metadata/ios/ja/promotional_text.txt b/fastlane/metadata/ios/ja/promotional_text.txt new file mode 100644 index 00000000000..2ef896328bd --- /dev/null +++ b/fastlane/metadata/ios/ja/promotional_text.txt @@ -0,0 +1,10 @@ +機能 + +* オープンソース +* 完全な暗号化 +* 隠匿設定 +* 柔軟な手数料 +* Replace-By-Fee (RBF) +* SegWit +* 閲覧専用ウォレット +* Lightningネットワーク diff --git a/fastlane/metadata/ios/ko/description.txt b/fastlane/metadata/ios/ko/description.txt new file mode 100644 index 00000000000..bc38f59b0a8 --- /dev/null +++ b/fastlane/metadata/ios/ko/description.txt @@ -0,0 +1,56 @@ +BlueWallet은 매우 간단하면서도 강력하고 안전한 Bitcoin 지갑입니다. 완전한 통제권을 유지하면서 Bitcoin을 보관하고 보내고 받으세요 — 당신의 키, 당신의 코인. + +무료이며 오픈 소스로, 커뮤니티를 위해 Bitcoin 사용자들이 만들었습니다. 전 세계 누구와도 거래하고, 주머니 속에서 바로 당신의 돈을 직접 관리하세요. 무제한으로 지갑을 무료로 만들거나 기존 지갑을 가져오세요 — 빠르고 간단합니다. + +_____ + +설계부터 안전하게 + +오픈 소스 +MIT 라이선스 — 직접 감사하고, 빌드하고, 실행하세요. React Native로 제작되었습니다. + +당신의 키, 당신의 코인 +개인 키(시드)는 절대 기기를 벗어나지 않습니다. 항상 당신이 통제합니다. + +멀티시그 금고 +Bitcoin에서 사용할 수 있는 최고의 보안 — 실제 금고처럼 자금을 쓰려면 여러 개의 키가 필요합니다. + +완전한 암호화 +기기 암호화에 더해, 모든 것이 당신만의 비밀번호로 다시 암호화됩니다. + +그럴듯한 부인 +잠금 해제를 강요받을 경우 가짜 지갑을 여는 미끼 비밀번호를 설정하세요. + +_____ + +강력함과 통제권 + +현대적인 지갑 +Legacy, SegWit, Taproot 주소를 완벽하게 지원하는 HD 지갑. + +하드웨어 지갑 +Coldcard, Keystone 및 기타 PSBT 서명 기기를 연결하고, 코인을 콜드 스토리지에 보관하세요. + +보기 전용 지갑 +개인 키를 노출하지 않고도 콜드 스토리지를 지켜보세요. + +자신만의 노드 운영 +Electrum을 통해 자신의 Bitcoin 풀 노드에 연결하고, Tor를 통해 연결을 라우팅하세요. + +코인 관리 +코인(UTXO)을 보고, 라벨을 붙이고, 동결하여 원하는 방식 그대로 트랜잭션을 구성하세요. + +유연한 수수료 +1 sat/vByte까지 직접 수수료를 설정하세요. 멈춘 트랜잭션을 RBF나 CPFP로 가속하거나 취소하세요. + +_____ + +LIGHTNING NETWORK + +설정이 전혀 필요 없는, 말도 안 되게 저렴하고 엄청나게 빠른 결제. Lightning 주소와 LNURL로 결제하고 결제받으세요. + +_____ + +iOS, Android, macOS에서 사용 가능. 다양한 통화와 언어 지원. 다크 모드. 계정도 추적도 없이 — 오직 Bitcoin뿐입니다. + +BlueWallet — 당신의 Bitcoin을 소유하세요. \ No newline at end of file diff --git a/fastlane/metadata/ios/ko/keywords.txt b/fastlane/metadata/ios/ko/keywords.txt new file mode 100644 index 00000000000..db7291b10a6 --- /dev/null +++ b/fastlane/metadata/ios/ko/keywords.txt @@ -0,0 +1 @@ +비트코인,지갑,비트코인 지갑,블록체인,btc,암호화폐,lightning,segwit,멀티시그,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/ko/name.txt b/fastlane/metadata/ios/ko/name.txt new file mode 100644 index 00000000000..ae1942c0d14 --- /dev/null +++ b/fastlane/metadata/ios/ko/name.txt @@ -0,0 +1 @@ +BlueWallet - 비트코인 지갑 diff --git a/fastlane/metadata/ios/ko/promotional_text.txt b/fastlane/metadata/ios/ko/promotional_text.txt new file mode 100644 index 00000000000..c8585cb6481 --- /dev/null +++ b/fastlane/metadata/ios/ko/promotional_text.txt @@ -0,0 +1,10 @@ +기능 + +* 오픈 소스 +* 완전한 암호화 +* 그럴듯한 부인 +* 유연한 수수료 +* Replace-By-Fee (RBF) +* SegWit +* 보기 전용 지갑 +* Lightning Network diff --git a/ios/fastlane/metadata/ms/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/ms/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/ms/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/ms/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/ms/description.txt b/fastlane/metadata/ios/ms/description.txt new file mode 100644 index 00000000000..015f473d19b --- /dev/null +++ b/fastlane/metadata/ios/ms/description.txt @@ -0,0 +1,56 @@ +BlueWallet ialah dompet Bitcoin yang sangat ringkas, berkuasa dan selamat. Simpan, hantar dan terima Bitcoin sambil kekal mengawal sepenuhnya — kunci anda, syiling anda. + +Percuma dan sumber terbuka, dibina oleh pengguna Bitcoin untuk komuniti. Berurus niaga dengan sesiapa sahaja di dunia dan kawal wang anda, terus dari poket anda. Cipta dompet tanpa had secara percuma atau pindah masuk yang sedia ada — pantas dan ringkas. + +_____ + +KESELAMATAN SECARA REKA BENTUK + +Sumber terbuka +Berlesen MIT — audit, bina dan jalankannya sendiri. Dibuat dengan React Native. + +Kunci anda, syiling anda +Kunci persendirian (frasa mnemonik anda) tidak pernah meninggalkan peranti anda. Anda sentiasa mengawal. + +Bilik Kebal Multisig +Keselamatan terbaik yang ada pada Bitcoin — berbilang kunci diperlukan untuk berbelanja, sama seperti bilik kebal sebenar. + +Penyulitan penuh +Selain penyulitan peranti, segalanya disulitkan semula dengan kata laluan anda sendiri. + +Penafian munasabah +Tetapkan kata laluan umpan yang membuka dompet palsu jika anda dipaksa membuka kunci. + +_____ + +KUASA DAN KAWALAN + +Dompet moden +Dompet HD dengan sokongan penuh untuk alamat Legacy, SegWit dan Taproot. + +Dompet perkakas +Gandingkan Coldcard, Keystone dan penandatangan PSBT lain, dan simpan syiling anda dalam simpanan sejuk. + +Dompet lihat sahaja +Pantau simpanan sejuk anda tanpa sekali pun mendedahkan kunci persendirian anda. + +Jalankan nod anda sendiri +Sambung ke nod penuh Bitcoin anda sendiri melalui Electrum, dan halakan sambungan anda melalui Tor. + +Kawalan duit +Lihat, labelkan dan bekukan syiling anda (UTXO) untuk membina urus niaga tepat seperti yang anda mahu. + +Yuran fleksibel +Tetapkan yuran anda sendiri, serendah 1 sat/vByte. Percepatkan urus niaga tersekat dengan RBF atau CPFP, atau batalkannya. + +_____ + +RANGKAIAN LIGHTNING + +Bayaran yang sangat murah dan amat pantas tanpa sebarang konfigurasi. Bayar dan dibayar dengan alamat Lightning dan LNURL. + +_____ + +Tersedia pada iOS, Android dan macOS. Pelbagai mata wang dan bahasa. Mod gelap. Tiada akaun dan tiada penjejakan — hanya Bitcoin. + +BlueWallet — miliki Bitcoin anda. \ No newline at end of file diff --git a/fastlane/metadata/ios/ms/keywords.txt b/fastlane/metadata/ios/ms/keywords.txt new file mode 100644 index 00000000000..5bc08340c4f --- /dev/null +++ b/fastlane/metadata/ios/ms/keywords.txt @@ -0,0 +1 @@ +bitcoin,dompet,dompet bitcoin,rantaian blok,btc,mata wang kripto,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/it/marketing_url.txt b/fastlane/metadata/ios/ms/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/it/marketing_url.txt rename to fastlane/metadata/ios/ms/marketing_url.txt diff --git a/fastlane/metadata/ios/ms/name.txt b/fastlane/metadata/ios/ms/name.txt new file mode 100644 index 00000000000..a4225e74ac8 --- /dev/null +++ b/fastlane/metadata/ios/ms/name.txt @@ -0,0 +1 @@ +BlueWallet - Dompet Bitcoin diff --git a/ios/fastlane/metadata/it/privacy_url.txt b/fastlane/metadata/ios/ms/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/it/privacy_url.txt rename to fastlane/metadata/ios/ms/privacy_url.txt diff --git a/fastlane/metadata/ios/ms/promotional_text.txt b/fastlane/metadata/ios/ms/promotional_text.txt new file mode 100644 index 00000000000..9212592b89a --- /dev/null +++ b/fastlane/metadata/ios/ms/promotional_text.txt @@ -0,0 +1,10 @@ +Ciri-ciri + +* Sumber Terbuka +* Penyulitan penuh +* Penafian munasabah +* Yuran fleksibel +* Ganti-Dengan-Yuran (RBF) +* SegWit +* Dompet lihat sahaja +* Rangkaian Lightning diff --git a/ios/fastlane/metadata/ms/release_notes.txt b/fastlane/metadata/ios/ms/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/ms/release_notes.txt rename to fastlane/metadata/ios/ms/release_notes.txt diff --git a/ios/fastlane/metadata/ms/subtitle.txt b/fastlane/metadata/ios/ms/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/ms/subtitle.txt rename to fastlane/metadata/ios/ms/subtitle.txt diff --git a/ios/fastlane/metadata/ms/support_url.txt b/fastlane/metadata/ios/ms/support_url.txt similarity index 100% rename from ios/fastlane/metadata/ms/support_url.txt rename to fastlane/metadata/ios/ms/support_url.txt diff --git a/ios/fastlane/metadata/nl-NL/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/nl-NL/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/nl-NL/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/nl-NL/description.txt b/fastlane/metadata/ios/nl-NL/description.txt new file mode 100644 index 00000000000..26c477e771b --- /dev/null +++ b/fastlane/metadata/ios/nl-NL/description.txt @@ -0,0 +1,56 @@ +BlueWallet is een radicaal eenvoudige, krachtige en veilige Bitcoin-wallet. Bewaar, verzend en ontvang Bitcoin terwijl je volledig de controle houdt — jouw sleutels, jouw munten. + +Gratis en open source, gebouwd door Bitcoin-gebruikers voor de gemeenschap. Doe transacties met iedereen ter wereld en neem de controle over je geld, direct vanuit je broekzak. Maak gratis onbeperkt wallets aan of importeer een bestaande — het is snel en eenvoudig. + +_____ + +VEILIGHEID DOOR ONTWERP + +Open source +MIT-gelicentieerd — controleer het, bouw het en draai het zelf. Gemaakt met React Native. + +Jouw sleutels, jouw munten +Privésleutels (je seed) verlaten nooit je apparaat. Jij hebt altijd de controle. + +Multisig Vault +De beste beveiliging die Bitcoin biedt — meerdere sleutels zijn nodig om uit te geven, net als bij een echte kluis. + +Volledige versleuteling +Bovenop de versleuteling van het apparaat wordt alles nogmaals versleuteld met je eigen wachtwoord. + +Plausibele ontkenning +Stel een lokwachtwoord in dat nepwallets opent als je ooit gedwongen wordt te ontgrendelen. + +_____ + +KRACHT EN CONTROLE + +Moderne wallets +HD-wallets met volledige ondersteuning voor Legacy-, SegWit- en Taproot-adressen. + +Hardware wallets +Koppel Coldcard, Keystone en andere PSBT-ondertekenaars en bewaar je munten in cold storage. + +Watch-only wallets +Houd je cold storage in de gaten zonder ooit je privésleutels bloot te geven. + +Draai je eigen node +Verbind met je eigen Bitcoin-full-node via Electrum en leid je verbinding via Tor. + +Coin control +Bekijk, label en bevries je munten (UTXO's) om transacties precies naar wens samen te stellen. + +Flexibele fees +Stel je eigen fee in, tot wel 1 sat/vByte. Versnel vastgelopen transacties met RBF of CPFP, of annuleer ze. + +_____ + +LIGHTNING NETWORK + +Oneerlijk goedkope en razendsnelle betalingen zonder enige configuratie. Betaal en word betaald met Lightning-adressen en LNURL. + +_____ + +Beschikbaar op iOS, Android en macOS. Meerdere valuta's en talen. Donkere modus. Geen accounts en geen tracking — gewoon Bitcoin. + +BlueWallet — bezit je eigen Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/nl-NL/keywords.txt b/fastlane/metadata/ios/nl-NL/keywords.txt new file mode 100644 index 00000000000..92c890a4fef --- /dev/null +++ b/fastlane/metadata/ios/nl-NL/keywords.txt @@ -0,0 +1 @@ +bitcoin,wallet,bitcoin wallet,blockchain,btc,cryptovaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/ms/marketing_url.txt b/fastlane/metadata/ios/nl-NL/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/ms/marketing_url.txt rename to fastlane/metadata/ios/nl-NL/marketing_url.txt diff --git a/fastlane/metadata/ios/nl-NL/name.txt b/fastlane/metadata/ios/nl-NL/name.txt new file mode 100644 index 00000000000..c60b1572b56 --- /dev/null +++ b/fastlane/metadata/ios/nl-NL/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoinwallet diff --git a/ios/fastlane/metadata/ms/privacy_url.txt b/fastlane/metadata/ios/nl-NL/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/ms/privacy_url.txt rename to fastlane/metadata/ios/nl-NL/privacy_url.txt diff --git a/fastlane/metadata/ios/nl-NL/promotional_text.txt b/fastlane/metadata/ios/nl-NL/promotional_text.txt new file mode 100644 index 00000000000..3a9391119cb --- /dev/null +++ b/fastlane/metadata/ios/nl-NL/promotional_text.txt @@ -0,0 +1,10 @@ +Functies + +* Open Source +* Volledige versleuteling +* Plausibele ontkenning +* Flexibele fees +* Replace-By-Fee (RBF) +* SegWit +* Watch-only wallets +* Lightning network diff --git a/ios/fastlane/metadata/nl-NL/release_notes.txt b/fastlane/metadata/ios/nl-NL/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/release_notes.txt rename to fastlane/metadata/ios/nl-NL/release_notes.txt diff --git a/ios/fastlane/metadata/nl-NL/subtitle.txt b/fastlane/metadata/ios/nl-NL/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/subtitle.txt rename to fastlane/metadata/ios/nl-NL/subtitle.txt diff --git a/ios/fastlane/metadata/nl-NL/support_url.txt b/fastlane/metadata/ios/nl-NL/support_url.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/support_url.txt rename to fastlane/metadata/ios/nl-NL/support_url.txt diff --git a/ios/fastlane/metadata/no/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/no/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/no/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/no/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/no/description.txt b/fastlane/metadata/ios/no/description.txt new file mode 100644 index 00000000000..e2a2cd117b1 --- /dev/null +++ b/fastlane/metadata/ios/no/description.txt @@ -0,0 +1,56 @@ +BlueWallet er en radikalt enkel, kraftig og sikker Bitcoin-lommebok. Oppbevar, send og motta Bitcoin mens du beholder full kontroll — dine nøkler, dine mynter. + +Gratis og åpen kildekode, laget av Bitcoin-brukere for fellesskapet. Handle med hvem som helst i verden og ta kontroll over pengene dine, rett fra lommen. Opprett ubegrenset antall lommebøker gratis eller importer en eksisterende — det er raskt og enkelt. + +_____ + +SIKKERHET I DESIGNET + +Åpen kildekode +MIT-lisensiert — gransk den, bygg den og kjør den selv. Laget med React Native. + +Dine nøkler, dine mynter +Private nøkler (din seed) forlater aldri enheten din. Du har alltid kontrollen. + +Multisig-hvelv +Den beste sikkerheten som finnes på Bitcoin — flere nøkler kreves for å bruke midlene, akkurat som et ekte hvelv. + +Full kryptering +I tillegg til enhetens kryptering krypteres alt på nytt med ditt eget passord. + +Plausibel fornektelse +Sett et lokkepassord som åpner falske lommebøker hvis du noen gang blir tvunget til å låse opp. + +_____ + +KRAFT OG KONTROLL + +Moderne lommebøker +HD-lommebøker med full støtte for Legacy-, SegWit- og Taproot-adresser. + +Maskinvarelommebøker +Koble til Coldcard, Keystone og andre PSBT-signerere, og hold myntene dine i kald lagring. + +Kun-observasjon-lommebøker +Hold øye med den kalde lagringen din uten å noen gang eksponere dine private nøkler. + +Kjør din egen node +Koble til din egen Bitcoin-fullnode via Electrum, og rut tilkoblingen din gjennom Tor. + +Myntkontroll +Se, merk og frys myntene dine (UTXO-er) for å bygge transaksjoner akkurat slik du vil. + +Fleksible gebyrer +Sett ditt eget gebyr, helt ned til 1 sat/vByte. Fremskynd fastlåste transaksjoner med RBF eller CPFP, eller kanseller dem. + +_____ + +LIGHTNING NETWORK + +Urettferdig billige og lynraske betalinger uten oppsett. Betal og få betalt med Lightning-adresser og LNURL. + +_____ + +Tilgjengelig på iOS, Android og macOS. Flere valutaer og språk. Mørk modus. Ingen kontoer og ingen sporing — bare Bitcoin. + +BlueWallet — eie din egen Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/no/keywords.txt b/fastlane/metadata/ios/no/keywords.txt new file mode 100644 index 00000000000..17c0bfadd00 --- /dev/null +++ b/fastlane/metadata/ios/no/keywords.txt @@ -0,0 +1 @@ +bitcoin,lommebok,bitcoin-lommebok,blokkjede,btc,kryptovaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/nl-NL/marketing_url.txt b/fastlane/metadata/ios/no/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/marketing_url.txt rename to fastlane/metadata/ios/no/marketing_url.txt diff --git a/ios/fastlane/metadata/pt-PT/name.txt b/fastlane/metadata/ios/no/name.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/name.txt rename to fastlane/metadata/ios/no/name.txt diff --git a/ios/fastlane/metadata/nl-NL/privacy_url.txt b/fastlane/metadata/ios/no/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/nl-NL/privacy_url.txt rename to fastlane/metadata/ios/no/privacy_url.txt diff --git a/fastlane/metadata/ios/no/promotional_text.txt b/fastlane/metadata/ios/no/promotional_text.txt new file mode 100644 index 00000000000..5615e1bfc7c --- /dev/null +++ b/fastlane/metadata/ios/no/promotional_text.txt @@ -0,0 +1,10 @@ +Funksjoner + +* Åpen kildekode +* Full kryptering +* Plausibel fornektelse +* Fleksible gebyrer +* Replace-By-Fee (RBF) +* SegWit +* Observasjonslommebøker +* Lightning network diff --git a/ios/fastlane/metadata/no/release_notes.txt b/fastlane/metadata/ios/no/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/no/release_notes.txt rename to fastlane/metadata/ios/no/release_notes.txt diff --git a/ios/fastlane/metadata/no/subtitle.txt b/fastlane/metadata/ios/no/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/no/subtitle.txt rename to fastlane/metadata/ios/no/subtitle.txt diff --git a/ios/fastlane/metadata/no/support_url.txt b/fastlane/metadata/ios/no/support_url.txt similarity index 100% rename from ios/fastlane/metadata/no/support_url.txt rename to fastlane/metadata/ios/no/support_url.txt diff --git a/ios/fastlane/metadata/pl/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/pl/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/pl/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/pl/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/pl/description.txt b/fastlane/metadata/ios/pl/description.txt new file mode 100644 index 00000000000..c4204fd70bd --- /dev/null +++ b/fastlane/metadata/ios/pl/description.txt @@ -0,0 +1,56 @@ +BlueWallet to radykalnie prosty, potężny i bezpieczny portfel Bitcoin. Przechowuj, wysyłaj i odbieraj Bitcoin, zachowując pełną kontrolę — Twoje klucze, Twoje monety. + +Darmowy i otwartoźródłowy, stworzony przez użytkowników Bitcoina dla społeczności. Dokonuj transakcji z każdym na świecie i przejmij kontrolę nad swoimi pieniędzmi, prosto z kieszeni. Twórz nieograniczoną liczbę portfeli za darmo lub zaimportuj istniejący — to szybkie i proste. + +_____ + +BEZPIECZEŃSTWO OD PODSTAW + +Otwarty kod źródłowy +Na licencji MIT — audytuj go, zbuduj i uruchom samodzielnie. Stworzony przy użyciu React Native. + +Twoje klucze, Twoje monety +Klucze prywatne (Twój seed) nigdy nie opuszczają Twojego urządzenia. Zawsze masz kontrolę. + +Skarbiec wielopodpisowy +Najlepsze zabezpieczenie dostępne w Bitcoinie — do wydania środków potrzeba wielu kluczy, zupełnie jak w prawdziwym skarbcu. + +Pełne szyfrowanie +Oprócz szyfrowania urządzenia wszystko jest dodatkowo szyfrowane Twoim własnym hasłem. + +Wiarygodna zaprzeczalność +Ustaw hasło-wabik, które otwiera fałszywe portfele, gdy ktoś zmusi Cię do odblokowania. + +_____ + +MOC I KONTROLA + +Nowoczesne portfele +Portfele HD z pełną obsługą adresów Legacy, SegWit i Taproot. + +Portfele sprzętowe +Sparuj Coldcard, Keystone i inne urządzenia podpisujące PSBT i trzymaj monety w zimnym przechowywaniu. + +Portfele tylko do odczytu +Miej oko na swoje zimne przechowywanie, nigdy nie ujawniając kluczy prywatnych. + +Uruchom własny węzeł +Połącz się z własnym pełnym węzłem Bitcoin przez Electrum i kieruj połączenie przez Tor. + +Kontrola monet +Przeglądaj, etykietuj i zamrażaj swoje monety (UTXO), aby budować transakcje dokładnie tak, jak chcesz. + +Elastyczne opłaty +Ustaw własną opłatę, nawet do 1 sat/vByte. Przyspieszaj utknięte transakcje za pomocą RBF lub CPFP albo je anuluj. + +_____ + +SIEĆ LIGHTNING + +Niewiarygodnie tanie i błyskawiczne płatności bez żadnej konfiguracji. Płać i otrzymuj płatności dzięki adresom Lightning i LNURL. + +_____ + +Dostępny na iOS, Androidzie i macOS. Wiele walut i języków. Tryb ciemny. Bez kont i bez śledzenia — po prostu Bitcoin. + +BlueWallet — bądź właścicielem swojego Bitcoina. \ No newline at end of file diff --git a/fastlane/metadata/ios/pl/keywords.txt b/fastlane/metadata/ios/pl/keywords.txt new file mode 100644 index 00000000000..0cca7776914 --- /dev/null +++ b/fastlane/metadata/ios/pl/keywords.txt @@ -0,0 +1 @@ +bitcoin,portfel,portfel bitcoin,blockchain,btc,kryptowaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/no/marketing_url.txt b/fastlane/metadata/ios/pl/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/no/marketing_url.txt rename to fastlane/metadata/ios/pl/marketing_url.txt diff --git a/fastlane/metadata/ios/pl/name.txt b/fastlane/metadata/ios/pl/name.txt new file mode 100644 index 00000000000..bed48bb0ca2 --- /dev/null +++ b/fastlane/metadata/ios/pl/name.txt @@ -0,0 +1 @@ +BlueWallet - Portfel Bitcoin diff --git a/ios/fastlane/metadata/no/privacy_url.txt b/fastlane/metadata/ios/pl/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/no/privacy_url.txt rename to fastlane/metadata/ios/pl/privacy_url.txt diff --git a/fastlane/metadata/ios/pl/promotional_text.txt b/fastlane/metadata/ios/pl/promotional_text.txt new file mode 100644 index 00000000000..c348f9f88b4 --- /dev/null +++ b/fastlane/metadata/ios/pl/promotional_text.txt @@ -0,0 +1,10 @@ +Funkcje + +* Otwarty kod źródłowy +* Pełne szyfrowanie +* Wiarygodna zaprzeczalność +* Elastyczne opłaty +* Replace-By-Fee (RBF) +* SegWit +* Tylko do odczytu +* Sieć Lightning diff --git a/ios/fastlane/metadata/pl/release_notes.txt b/fastlane/metadata/ios/pl/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/pl/release_notes.txt rename to fastlane/metadata/ios/pl/release_notes.txt diff --git a/ios/fastlane/metadata/pl/subtitle.txt b/fastlane/metadata/ios/pl/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/pl/subtitle.txt rename to fastlane/metadata/ios/pl/subtitle.txt diff --git a/ios/fastlane/metadata/pl/support_url.txt b/fastlane/metadata/ios/pl/support_url.txt similarity index 100% rename from ios/fastlane/metadata/pl/support_url.txt rename to fastlane/metadata/ios/pl/support_url.txt diff --git a/ios/fastlane/metadata/primary_category.txt b/fastlane/metadata/ios/primary_category.txt similarity index 100% rename from ios/fastlane/metadata/primary_category.txt rename to fastlane/metadata/ios/primary_category.txt diff --git a/ios/fastlane/metadata/primary_first_sub_category.txt b/fastlane/metadata/ios/primary_first_sub_category.txt similarity index 100% rename from ios/fastlane/metadata/primary_first_sub_category.txt rename to fastlane/metadata/ios/primary_first_sub_category.txt diff --git a/ios/fastlane/metadata/primary_second_sub_category.txt b/fastlane/metadata/ios/primary_second_sub_category.txt similarity index 100% rename from ios/fastlane/metadata/primary_second_sub_category.txt rename to fastlane/metadata/ios/primary_second_sub_category.txt diff --git a/ios/fastlane/metadata/pt-BR/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/pt-BR/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/pt-BR/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/pt-BR/description.txt b/fastlane/metadata/ios/pt-BR/description.txt new file mode 100644 index 00000000000..a48ae2c1aef --- /dev/null +++ b/fastlane/metadata/ios/pt-BR/description.txt @@ -0,0 +1,56 @@ +BlueWallet é uma carteira Bitcoin radicalmente simples, poderosa e segura. Armazene, envie e receba Bitcoin mantendo o controle total — suas chaves, suas moedas. + +Gratuita e de código aberto, criada por usuários de Bitcoin para a comunidade. Faça transações com qualquer pessoa no mundo e assuma o controle do seu dinheiro, direto do seu bolso. Crie carteiras ilimitadas gratuitamente ou importe uma existente — é rápido e simples. + +_____ + +SEGURANÇA POR PRINCÍPIO + +Código aberto +Licença MIT — audite, compile e rode você mesmo. Feita com React Native. + +Suas chaves, suas moedas +As chaves privadas (sua seed) nunca saem do seu dispositivo. Você está sempre no controle. + +Cofre Multisig +A melhor segurança disponível no Bitcoin — várias chaves são necessárias para gastar, como em um cofre de verdade. + +Criptografia completa +Além da criptografia do dispositivo, tudo é criptografado novamente com a sua própria senha. + +Negação plausível +Defina uma senha falsa que abre carteiras falsas caso você seja forçado a desbloquear. + +_____ + +PODER E CONTROLE + +Carteiras modernas +Carteiras HD com suporte completo a endereços Legacy, SegWit e Taproot. + +Carteiras hardware +Conecte Coldcard, Keystone e outros assinadores PSBT e mantenha suas moedas em armazenamento frio. + +Carteiras somente leitura +Fique de olho no seu armazenamento frio sem nunca expor suas chaves privadas. + +Rode seu próprio nó +Conecte-se ao seu próprio nó completo Bitcoin via Electrum e roteie sua conexão pelo Tor. + +Controle de moedas +Veja, rotule e congele suas moedas (UTXOs) para montar transações exatamente do jeito que você quer. + +Taxas flexíveis +Defina sua própria taxa, até 1 sat/vByte. Acelere transações travadas com RBF ou CPFP, ou cancele-as. + +_____ + +LIGHTNING NETWORK + +Pagamentos absurdamente baratos e extremamente rápidos, sem configuração alguma. Pague e receba com endereços Lightning e LNURL. + +_____ + +Disponível para iOS, Android e macOS. Várias moedas e idiomas. Modo escuro. Sem contas e sem rastreamento — apenas Bitcoin. + +BlueWallet — seja dono do seu Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/pt-BR/keywords.txt b/fastlane/metadata/ios/pt-BR/keywords.txt new file mode 100644 index 00000000000..30b12fe8d37 --- /dev/null +++ b/fastlane/metadata/ios/pt-BR/keywords.txt @@ -0,0 +1 @@ +bitcoin,carteira,carteira bitcoin,blockchain,btc,criptomoeda,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/pl/marketing_url.txt b/fastlane/metadata/ios/pt-BR/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/pl/marketing_url.txt rename to fastlane/metadata/ios/pt-BR/marketing_url.txt diff --git a/fastlane/metadata/ios/pt-BR/name.txt b/fastlane/metadata/ios/pt-BR/name.txt new file mode 100644 index 00000000000..2059b09b5d8 --- /dev/null +++ b/fastlane/metadata/ios/pt-BR/name.txt @@ -0,0 +1 @@ +BlueWallet - Carteira Bitcoin diff --git a/ios/fastlane/metadata/pl/privacy_url.txt b/fastlane/metadata/ios/pt-BR/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/pl/privacy_url.txt rename to fastlane/metadata/ios/pt-BR/privacy_url.txt diff --git a/fastlane/metadata/ios/pt-BR/promotional_text.txt b/fastlane/metadata/ios/pt-BR/promotional_text.txt new file mode 100644 index 00000000000..4caae9c0906 --- /dev/null +++ b/fastlane/metadata/ios/pt-BR/promotional_text.txt @@ -0,0 +1,10 @@ +Recursos + +* Código aberto +* Criptografia completa +* Negação plausível +* Taxas flexíveis +* Replace-By-Fee (RBF) +* SegWit +* Carteiras somente leitura +* Lightning Network diff --git a/ios/fastlane/metadata/pt-BR/release_notes.txt b/fastlane/metadata/ios/pt-BR/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/release_notes.txt rename to fastlane/metadata/ios/pt-BR/release_notes.txt diff --git a/ios/fastlane/metadata/pt-BR/subtitle.txt b/fastlane/metadata/ios/pt-BR/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/subtitle.txt rename to fastlane/metadata/ios/pt-BR/subtitle.txt diff --git a/ios/fastlane/metadata/pt-BR/support_url.txt b/fastlane/metadata/ios/pt-BR/support_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/support_url.txt rename to fastlane/metadata/ios/pt-BR/support_url.txt diff --git a/ios/fastlane/metadata/pt-PT/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/pt-PT/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/pt-PT/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/pt-PT/description.txt b/fastlane/metadata/ios/pt-PT/description.txt new file mode 100644 index 00000000000..6f7da02f028 --- /dev/null +++ b/fastlane/metadata/ios/pt-PT/description.txt @@ -0,0 +1,56 @@ +A BlueWallet é uma carteira de Bitcoin radicalmente simples, poderosa e segura. Guarda, envia e recebe Bitcoin mantendo o controlo total — as tuas chaves, as tuas moedas. + +Gratuita e de código aberto, criada por utilizadores de Bitcoin para a comunidade. Faz transações com qualquer pessoa no mundo e assume o controlo do teu dinheiro, diretamente do teu bolso. Cria carteiras ilimitadas gratuitamente ou importa uma já existente — é rápido e simples. + +_____ + +SEGURANÇA DESDE A CONCEÇÃO + +Código aberto +Licença MIT — audita-a, compila-a e executa-a por ti próprio. Feita com React Native. + +As tuas chaves, as tuas moedas +As chaves privadas (a tua seed) nunca saem do teu dispositivo. Tens sempre o controlo. + +Cofre Multiassinatura +A melhor segurança disponível no Bitcoin — são necessárias várias chaves para gastar, tal como num cofre real. + +Encriptação completa +Além da encriptação do dispositivo, tudo é encriptado novamente com a tua própria palavra-passe. + +Negação plausível +Define uma palavra-passe falsa que abre carteiras falsas caso sejas alguma vez forçado a desbloquear. + +_____ + +PODER E CONTROLO + +Carteiras modernas +Carteiras HD com suporte total para endereços Legacy, SegWit e Taproot. + +Carteiras de hardware +Emparelha a Coldcard, a Keystone e outros assinadores PSBT, e mantém as tuas moedas em cold storage. + +Carteiras só de observação +Vigia o teu cold storage sem nunca expores as tuas chaves privadas. + +Executa o teu próprio nó +Liga-te ao teu próprio nó completo de Bitcoin através de Electrum e encaminha a tua ligação através de Tor. + +Controlo de moedas +Vê, etiqueta e congela as tuas moedas (UTXOs) para construir transações exatamente como queres. + +Taxas flexíveis +Define a tua própria taxa, até 1 sat/vByte. Acelera transações presas com RBF ou CPFP, ou cancela-as. + +_____ + +LIGHTNING NETWORK + +Pagamentos absurdamente baratos e extremamente rápidos sem qualquer configuração. Paga e recebe pagamentos com endereços Lightning e LNURL. + +_____ + +Disponível em iOS, Android e macOS. Várias moedas e idiomas. Modo escuro. Sem contas e sem rastreio — apenas Bitcoin. + +BlueWallet — sê dono do teu Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/pt-PT/keywords.txt b/fastlane/metadata/ios/pt-PT/keywords.txt new file mode 100644 index 00000000000..30b12fe8d37 --- /dev/null +++ b/fastlane/metadata/ios/pt-PT/keywords.txt @@ -0,0 +1 @@ +bitcoin,carteira,carteira bitcoin,blockchain,btc,criptomoeda,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/pt-BR/marketing_url.txt b/fastlane/metadata/ios/pt-PT/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/marketing_url.txt rename to fastlane/metadata/ios/pt-PT/marketing_url.txt diff --git a/ios/fastlane/metadata/th/name.txt b/fastlane/metadata/ios/pt-PT/name.txt similarity index 100% rename from ios/fastlane/metadata/th/name.txt rename to fastlane/metadata/ios/pt-PT/name.txt diff --git a/ios/fastlane/metadata/pt-BR/privacy_url.txt b/fastlane/metadata/ios/pt-PT/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-BR/privacy_url.txt rename to fastlane/metadata/ios/pt-PT/privacy_url.txt diff --git a/fastlane/metadata/ios/pt-PT/promotional_text.txt b/fastlane/metadata/ios/pt-PT/promotional_text.txt new file mode 100644 index 00000000000..666837a9457 --- /dev/null +++ b/fastlane/metadata/ios/pt-PT/promotional_text.txt @@ -0,0 +1,10 @@ +Funcionalidades + +* Código aberto +* Encriptação total +* Negação plausível +* Taxas flexíveis +* Substituição por Taxa (RBF) +* SegWit +* Só de observação +* Lightning network diff --git a/ios/fastlane/metadata/pt-PT/release_notes.txt b/fastlane/metadata/ios/pt-PT/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/release_notes.txt rename to fastlane/metadata/ios/pt-PT/release_notes.txt diff --git a/ios/fastlane/metadata/pt-PT/subtitle.txt b/fastlane/metadata/ios/pt-PT/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/subtitle.txt rename to fastlane/metadata/ios/pt-PT/subtitle.txt diff --git a/ios/fastlane/metadata/pt-PT/support_url.txt b/fastlane/metadata/ios/pt-PT/support_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/support_url.txt rename to fastlane/metadata/ios/pt-PT/support_url.txt diff --git a/ios/fastlane/metadata/review_information/demo_password.txt b/fastlane/metadata/ios/review_information/demo_password.txt similarity index 100% rename from ios/fastlane/metadata/review_information/demo_password.txt rename to fastlane/metadata/ios/review_information/demo_password.txt diff --git a/ios/fastlane/metadata/review_information/demo_user.txt b/fastlane/metadata/ios/review_information/demo_user.txt similarity index 100% rename from ios/fastlane/metadata/review_information/demo_user.txt rename to fastlane/metadata/ios/review_information/demo_user.txt diff --git a/ios/fastlane/metadata/review_information/email_address.txt b/fastlane/metadata/ios/review_information/email_address.txt similarity index 100% rename from ios/fastlane/metadata/review_information/email_address.txt rename to fastlane/metadata/ios/review_information/email_address.txt diff --git a/ios/fastlane/metadata/review_information/first_name.txt b/fastlane/metadata/ios/review_information/first_name.txt similarity index 100% rename from ios/fastlane/metadata/review_information/first_name.txt rename to fastlane/metadata/ios/review_information/first_name.txt diff --git a/ios/fastlane/metadata/review_information/last_name.txt b/fastlane/metadata/ios/review_information/last_name.txt similarity index 100% rename from ios/fastlane/metadata/review_information/last_name.txt rename to fastlane/metadata/ios/review_information/last_name.txt diff --git a/ios/fastlane/metadata/review_information/notes.txt b/fastlane/metadata/ios/review_information/notes.txt similarity index 100% rename from ios/fastlane/metadata/review_information/notes.txt rename to fastlane/metadata/ios/review_information/notes.txt diff --git a/ios/fastlane/metadata/review_information/phone_number.txt b/fastlane/metadata/ios/review_information/phone_number.txt similarity index 100% rename from ios/fastlane/metadata/review_information/phone_number.txt rename to fastlane/metadata/ios/review_information/phone_number.txt diff --git a/ios/fastlane/metadata/ro/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/ro/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/ro/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/ro/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/ro/description.txt b/fastlane/metadata/ios/ro/description.txt new file mode 100644 index 00000000000..198f25cc29d --- /dev/null +++ b/fastlane/metadata/ios/ro/description.txt @@ -0,0 +1,56 @@ +BlueWallet este un portofel Bitcoin radical de simplu, puternic și securizat. Stochează, trimite și primește Bitcoin păstrând controlul total — cheile tale, monedele tale. + +Gratuit și open source, creat de utilizatori Bitcoin pentru comunitate. Fă tranzacții cu oricine din lume și preia controlul banilor tăi, direct din buzunar. Creează portofele nelimitate gratuit sau importă unul existent — e rapid și simplu. + +_____ + +SECURITATE PRIN CONCEPȚIE + +Open source +Licențiat MIT — auditează-l, compilează-l și rulează-l singur. Realizat cu React Native. + +Cheile tale, monedele tale +Cheile private (seed-ul tău) nu părăsesc niciodată dispozitivul. Tu deții mereu controlul. + +Seif multisig +Cea mai bună securitate disponibilă pe Bitcoin — sunt necesare mai multe chei pentru a cheltui, exact ca un seif adevărat. + +Criptare completă +Pe lângă criptarea dispozitivului, totul este criptat din nou cu propria ta parolă. + +Negare plauzibilă +Setează o parolă-momeală care deschide portofele false dacă ești vreodată forțat să deblochezi. + +_____ + +PUTERE ȘI CONTROL + +Portofele moderne +Portofele HD cu suport complet pentru adrese Legacy, SegWit și Taproot. + +Portofele hardware +Asociază Coldcard, Keystone și alte dispozitive de semnare PSBT și ține-ți monedele în stocare la rece. + +Portofele doar citire +Supraveghează-ți stocarea la rece fără a-ți expune vreodată cheile private. + +Rulează-ți propriul nod +Conectează-te la propriul nod Bitcoin complet prin Electrum și direcționează-ți conexiunea prin Tor. + +Controlul monedelor +Vezi, etichetează și îngheață-ți monedele (UTXO-uri) pentru a construi tranzacții exact cum vrei. + +Comisioane flexibile +Stabilește-ți propriul comision, până la 1 sat/vByte. Accelerează tranzacțiile blocate cu RBF sau CPFP, sau anulează-le. + +_____ + +REȚEAUA LIGHTNING + +Plăți incredibil de ieftine și extrem de rapide, fără configurare. Plătește și încasează cu adrese Lightning și LNURL. + +_____ + +Disponibil pe iOS, Android și macOS. Mai multe valute și limbi. Mod întunecat. Fără conturi și fără urmărire — doar Bitcoin. + +BlueWallet — deține-ți Bitcoinul. \ No newline at end of file diff --git a/fastlane/metadata/ios/ro/keywords.txt b/fastlane/metadata/ios/ro/keywords.txt new file mode 100644 index 00000000000..8f3f5c65bba --- /dev/null +++ b/fastlane/metadata/ios/ro/keywords.txt @@ -0,0 +1 @@ +bitcoin,portofel,portofel bitcoin,blockchain,btc,criptomonedă,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/pt-PT/marketing_url.txt b/fastlane/metadata/ios/ro/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/marketing_url.txt rename to fastlane/metadata/ios/ro/marketing_url.txt diff --git a/fastlane/metadata/ios/ro/name.txt b/fastlane/metadata/ios/ro/name.txt new file mode 100644 index 00000000000..c2eec874f4e --- /dev/null +++ b/fastlane/metadata/ios/ro/name.txt @@ -0,0 +1 @@ +BlueWallet - Portofel Bitcoin diff --git a/ios/fastlane/metadata/pt-PT/privacy_url.txt b/fastlane/metadata/ios/ro/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/pt-PT/privacy_url.txt rename to fastlane/metadata/ios/ro/privacy_url.txt diff --git a/fastlane/metadata/ios/ro/promotional_text.txt b/fastlane/metadata/ios/ro/promotional_text.txt new file mode 100644 index 00000000000..789274c0045 --- /dev/null +++ b/fastlane/metadata/ios/ro/promotional_text.txt @@ -0,0 +1,10 @@ +Funcționalități + +* Open Source +* Criptare completă +* Negare plauzibilă +* Comisioane flexibile +* Replace-By-Fee (RBF) +* SegWit +* Portofele doar citire +* Rețeaua Lightning diff --git a/ios/fastlane/metadata/ro/release_notes.txt b/fastlane/metadata/ios/ro/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/ro/release_notes.txt rename to fastlane/metadata/ios/ro/release_notes.txt diff --git a/ios/fastlane/metadata/ro/subtitle.txt b/fastlane/metadata/ios/ro/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/ro/subtitle.txt rename to fastlane/metadata/ios/ro/subtitle.txt diff --git a/ios/fastlane/metadata/ro/support_url.txt b/fastlane/metadata/ios/ro/support_url.txt similarity index 100% rename from ios/fastlane/metadata/ro/support_url.txt rename to fastlane/metadata/ios/ro/support_url.txt diff --git a/ios/fastlane/metadata/ru/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/ru/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/ru/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/ru/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/ru/description.txt b/fastlane/metadata/ios/ru/description.txt new file mode 100644 index 00000000000..8d0f07ede73 --- /dev/null +++ b/fastlane/metadata/ios/ru/description.txt @@ -0,0 +1,56 @@ +BlueWallet — это предельно простой, мощный и безопасный Bitcoin-кошелёк. Храните, отправляйте и получайте биткойны, сохраняя полный контроль — ваши ключи, ваши монеты. + +Бесплатный и с открытым исходным кодом, созданный пользователями Bitcoin для сообщества. Совершайте транзакции с кем угодно в мире и управляйте своими деньгами прямо из кармана. Создавайте неограниченное число кошельков бесплатно или импортируйте существующий — это быстро и просто. + +_____ + +БЕЗОПАСНОСТЬ В ОСНОВЕ + +Открытый исходный код +Лицензия MIT — проверяйте, собирайте и запускайте сами. Создано на React Native. + +Ваши ключи, ваши монеты +Приватные ключи (ваша сид-фраза) никогда не покидают устройство. Вы всегда сохраняете контроль. + +Хранилище с мультиподписью +Лучшая защита, доступная в Bitcoin — для траты требуется несколько ключей, как в настоящем сейфе. + +Полное шифрование +Помимо шифрования устройства, всё дополнительно шифруется вашим собственным паролем. + +Двойное дно +Задайте подменный пароль, который открывает фальшивые кошельки, если вас заставят разблокировать приложение. + +_____ + +МОЩНОСТЬ И КОНТРОЛЬ + +Современные кошельки +HD-кошельки с полной поддержкой адресов Legacy, SegWit и Taproot. + +Аппаратные кошельки +Подключайте Coldcard, Keystone и другие PSBT-подписанты и храните монеты в холодном хранилище. + +Кошельки только для просмотра +Следите за своим холодным хранилищем, не раскрывая приватные ключи. + +Запустите собственный узел +Подключайтесь к собственному полному узлу Bitcoin через Electrum и направляйте соединение через Tor. + +Управление монетами +Просматривайте, помечайте и замораживайте свои монеты (UTXO), чтобы составлять транзакции именно так, как вам нужно. + +Гибкие комиссии +Задавайте собственную комиссию, вплоть до 1 sat/vByte. Ускоряйте застрявшие транзакции с помощью RBF или CPFP или отменяйте их. + +_____ + +LIGHTNING NETWORK + +Несправедливо дешёвые и молниеносно быстрые платежи без какой-либо настройки. Платите и принимайте оплату с помощью Lightning-адресов и LNURL. + +_____ + +Доступно на iOS, Android и macOS. Множество валют и языков. Тёмная тема. Никаких аккаунтов и никакого отслеживания — только Bitcoin. + +BlueWallet — владейте своими биткойнами. \ No newline at end of file diff --git a/fastlane/metadata/ios/ru/keywords.txt b/fastlane/metadata/ios/ru/keywords.txt new file mode 100644 index 00000000000..a185a5a8c9b --- /dev/null +++ b/fastlane/metadata/ios/ru/keywords.txt @@ -0,0 +1 @@ +биткойн,кошелёк,Bitcoin-кошелёк,блокчейн,btc,криптовалюта,lightning,segwit,мультиподпись,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/ro/marketing_url.txt b/fastlane/metadata/ios/ru/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/ro/marketing_url.txt rename to fastlane/metadata/ios/ru/marketing_url.txt diff --git a/ios/fastlane/metadata/ru/name.txt b/fastlane/metadata/ios/ru/name.txt similarity index 100% rename from ios/fastlane/metadata/ru/name.txt rename to fastlane/metadata/ios/ru/name.txt diff --git a/ios/fastlane/metadata/ro/privacy_url.txt b/fastlane/metadata/ios/ru/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/ro/privacy_url.txt rename to fastlane/metadata/ios/ru/privacy_url.txt diff --git a/fastlane/metadata/ios/ru/promotional_text.txt b/fastlane/metadata/ios/ru/promotional_text.txt new file mode 100644 index 00000000000..40a6e601bda --- /dev/null +++ b/fastlane/metadata/ios/ru/promotional_text.txt @@ -0,0 +1,10 @@ +Возможности + +* Открытый код +* Полное шифрование +* Двойное дно +* Гибкие комиссии +* Replace-By-Fee (RBF) +* SegWit +* Кошельки только для просмотра +* Сеть Lightning diff --git a/ios/fastlane/metadata/ru/release_notes.txt b/fastlane/metadata/ios/ru/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/ru/release_notes.txt rename to fastlane/metadata/ios/ru/release_notes.txt diff --git a/ios/fastlane/metadata/ru/subtitle.txt b/fastlane/metadata/ios/ru/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/ru/subtitle.txt rename to fastlane/metadata/ios/ru/subtitle.txt diff --git a/ios/fastlane/metadata/ru/support_url.txt b/fastlane/metadata/ios/ru/support_url.txt similarity index 100% rename from ios/fastlane/metadata/ru/support_url.txt rename to fastlane/metadata/ios/ru/support_url.txt diff --git a/ios/fastlane/metadata/secondary_category.txt b/fastlane/metadata/ios/secondary_category.txt similarity index 100% rename from ios/fastlane/metadata/secondary_category.txt rename to fastlane/metadata/ios/secondary_category.txt diff --git a/ios/fastlane/metadata/secondary_first_sub_category.txt b/fastlane/metadata/ios/secondary_first_sub_category.txt similarity index 100% rename from ios/fastlane/metadata/secondary_first_sub_category.txt rename to fastlane/metadata/ios/secondary_first_sub_category.txt diff --git a/ios/fastlane/metadata/secondary_second_sub_category.txt b/fastlane/metadata/ios/secondary_second_sub_category.txt similarity index 100% rename from ios/fastlane/metadata/secondary_second_sub_category.txt rename to fastlane/metadata/ios/secondary_second_sub_category.txt diff --git a/ios/fastlane/metadata/sv/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/sv/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/sv/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/sv/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/sv/description.txt b/fastlane/metadata/ios/sv/description.txt new file mode 100644 index 00000000000..0c2f9bd6772 --- /dev/null +++ b/fastlane/metadata/ios/sv/description.txt @@ -0,0 +1,56 @@ +BlueWallet är en radikalt enkel, kraftfull och säker Bitcoin-plånbok. Lagra, skicka och ta emot Bitcoin med full kontroll — dina nycklar, dina mynt. + +Gratis och öppen källkod, byggd av Bitcoin-användare för gemenskapen. Handla med vem som helst i världen och ta kontroll över dina pengar, direkt från fickan. Skapa obegränsat med plånböcker gratis eller importera en befintlig — det är snabbt och enkelt. + +_____ + +SÄKERHET GENOM DESIGN + +Öppen källkod +MIT-licensierad — granska den, bygg den och kör den själv. Gjord med React Native. + +Dina nycklar, dina mynt +Privata nycklar (din seed) lämnar aldrig din enhet. Du har alltid kontrollen. + +Multisig Valv +Den bästa säkerheten som finns på Bitcoin — flera nycklar krävs för att spendera, precis som ett riktigt valv. + +Full kryptering +Utöver enhetens kryptering krypteras allt igen med ditt eget lösenord. + +Trovärdigt förnekande +Ange ett lockbeteslösenord som öppnar falska plånböcker om du någonsin tvingas låsa upp. + +_____ + +KRAFT OCH KONTROLL + +Moderna plånböcker +HD-plånböcker med fullt stöd för Legacy-, SegWit- och Taproot-adresser. + +Hårdvaruplånböcker +Para ihop Coldcard, Keystone och andra PSBT-signerare, och förvara dina mynt i kallförvaring. + +Plånböcker för endast granskning +Håll ett öga på din kallförvaring utan att någonsin exponera dina privata nycklar. + +Kör din egen nod +Anslut till din egen Bitcoin-fullnod via Electrum, och dirigera din anslutning genom Tor. + +Myntkontroll +Se, märk och frys dina mynt (UTXO:er) för att bygga transaktioner precis som du vill. + +Flexibla avgifter +Ange din egen avgift, ner till 1 sat/vByte. Påskynda fastnade transaktioner med RBF eller CPFP, eller avbryt dem. + +_____ + +LIGHTNING-NÄTVERKET + +Orättvist billiga och blixtsnabba betalningar utan konfiguration. Betala och få betalt med Lightning-adresser och LNURL. + +_____ + +Tillgänglig på iOS, Android och macOS. Flera valutor och språk. Mörkt läge. Inga konton och ingen spårning — bara Bitcoin. + +BlueWallet — äg din Bitcoin. \ No newline at end of file diff --git a/fastlane/metadata/ios/sv/keywords.txt b/fastlane/metadata/ios/sv/keywords.txt new file mode 100644 index 00000000000..2a67e1ec68f --- /dev/null +++ b/fastlane/metadata/ios/sv/keywords.txt @@ -0,0 +1 @@ +bitcoin,plånbok,bitcoin-plånbok,blockkedja,btc,kryptovaluta,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/ru/marketing_url.txt b/fastlane/metadata/ios/sv/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/ru/marketing_url.txt rename to fastlane/metadata/ios/sv/marketing_url.txt diff --git a/fastlane/metadata/ios/sv/name.txt b/fastlane/metadata/ios/sv/name.txt new file mode 100644 index 00000000000..10c86f43da9 --- /dev/null +++ b/fastlane/metadata/ios/sv/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin Plånbok diff --git a/ios/fastlane/metadata/ru/privacy_url.txt b/fastlane/metadata/ios/sv/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/ru/privacy_url.txt rename to fastlane/metadata/ios/sv/privacy_url.txt diff --git a/fastlane/metadata/ios/sv/promotional_text.txt b/fastlane/metadata/ios/sv/promotional_text.txt new file mode 100644 index 00000000000..9f59f2c2092 --- /dev/null +++ b/fastlane/metadata/ios/sv/promotional_text.txt @@ -0,0 +1,10 @@ +Funktioner + +* Öppen källkod +* Full kryptering +* Trovärdigt förnekande +* Flexibla avgifter +* Replace-By-Fee (RBF) +* SegWit +* Endast granskning +* Lightning-nätverket diff --git a/ios/fastlane/metadata/sv/release_notes.txt b/fastlane/metadata/ios/sv/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/sv/release_notes.txt rename to fastlane/metadata/ios/sv/release_notes.txt diff --git a/ios/fastlane/metadata/sv/subtitle.txt b/fastlane/metadata/ios/sv/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/sv/subtitle.txt rename to fastlane/metadata/ios/sv/subtitle.txt diff --git a/ios/fastlane/metadata/sv/support_url.txt b/fastlane/metadata/ios/sv/support_url.txt similarity index 100% rename from ios/fastlane/metadata/sv/support_url.txt rename to fastlane/metadata/ios/sv/support_url.txt diff --git a/ios/fastlane/metadata/th/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/th/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/th/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/th/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/th/description.txt b/fastlane/metadata/ios/th/description.txt new file mode 100644 index 00000000000..4190f17fd53 --- /dev/null +++ b/fastlane/metadata/ios/th/description.txt @@ -0,0 +1,56 @@ +BlueWallet เป็นกระเป๋าสตางค์ Bitcoin ที่เรียบง่าย ทรงพลัง และปลอดภัยอย่างยิ่ง เก็บ ส่ง และรับ Bitcoin ได้โดยที่คุณควบคุมเต็มที่ — กุญแจของคุณ เหรียญของคุณ + +ฟรีและโอเพนซอร์ส สร้างโดยผู้ใช้ Bitcoin เพื่อชุมชน ทำธุรกรรมกับใครก็ได้ทั่วโลกและกุมบังเหียนเงินของคุณได้จากกระเป๋าของคุณเอง สร้างกระเป๋าสตางค์ได้ไม่จำกัดฟรีหรือนำเข้ากระเป๋าที่มีอยู่แล้ว — รวดเร็วและง่ายดาย + +_____ + +ความปลอดภัยตั้งแต่การออกแบบ + +โอเพนซอร์ส +อนุญาตแบบ MIT — ตรวจสอบ สร้าง และรันได้ด้วยตัวคุณเอง สร้างด้วย React Native + +กุญแจของคุณ เหรียญของคุณ +กุญแจส่วนตัว (ซีดของคุณ) ไม่เคยออกจากอุปกรณ์ของคุณ คุณควบคุมได้เสมอ + +ห้องนิรภัยมัลติซิก +ความปลอดภัยที่ดีที่สุดที่มีบน Bitcoin — ต้องใช้กุญแจหลายดอกในการใช้จ่าย เหมือนห้องนิรภัยของจริง + +การเข้ารหัสเต็มรูปแบบ +นอกเหนือจากการเข้ารหัสของอุปกรณ์ ทุกอย่างยังถูกเข้ารหัสอีกครั้งด้วยรหัสผ่านของคุณเอง + +การปฏิเสธที่เป็นไปได้ +ตั้งรหัสผ่านลวงที่จะเปิดกระเป๋าสตางค์ปลอมหากคุณถูกบังคับให้ปลดล็อก + +_____ + +พลังและการควบคุม + +กระเป๋าสตางค์สมัยใหม่ +กระเป๋าสตางค์ HD ที่รองรับแอดเดรสแบบ Legacy, SegWit และ Taproot เต็มรูปแบบ + +กระเป๋าฮาร์ดแวร์ +จับคู่ Coldcard, Keystone และเครื่องลงนาม PSBT อื่น ๆ และเก็บเหรียญของคุณไว้ในที่เก็บแบบเย็น + +กระเป๋าสตางค์แบบดูอย่างเดียว +เฝ้าดูที่เก็บแบบเย็นของคุณโดยไม่ต้องเปิดเผยกุญแจส่วนตัวของคุณเลย + +รันโหนดของคุณเอง +เชื่อมต่อกับฟูลโหนด Bitcoin ของคุณเองผ่าน Electrum และกำหนดเส้นทางการเชื่อมต่อผ่าน Tor + +การควบคุมเหรียญ +ดู ติดป้ายกำกับ และระงับเหรียญของคุณ (UTXO) เพื่อสร้างธุรกรรมได้ตรงตามที่คุณต้องการ + +ค่าธรรมเนียมที่ยืดหยุ่น +ตั้งค่าธรรมเนียมของคุณเองได้ต่ำสุดถึง 1 sat/vByte เพิ่มความเร็วธุรกรรมที่ค้างด้วย RBF หรือ CPFP หรือยกเลิกธุรกรรม + +_____ + +เครือข่าย LIGHTNING + +การชำระเงินที่ถูกอย่างไม่น่าเชื่อและรวดเร็วสุด ๆ โดยไม่ต้องตั้งค่าใด ๆ จ่ายและรับเงินด้วยแอดเดรส Lightning และ LNURL + +_____ + +ใช้ได้บน iOS, Android และ macOS รองรับหลายสกุลเงินและหลายภาษา โหมดมืด ไม่มีบัญชีและไม่มีการติดตาม — มีแค่ Bitcoin + +BlueWallet — เป็นเจ้าของ Bitcoin ของคุณ \ No newline at end of file diff --git a/fastlane/metadata/ios/th/keywords.txt b/fastlane/metadata/ios/th/keywords.txt new file mode 100644 index 00000000000..d2ff42d4dc5 --- /dev/null +++ b/fastlane/metadata/ios/th/keywords.txt @@ -0,0 +1 @@ +บิตคอยน์,กระเป๋าสตางค์,กระเป๋าบิตคอยน์,บล็อกเชน,btc,คริปโต,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/sv/marketing_url.txt b/fastlane/metadata/ios/th/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/sv/marketing_url.txt rename to fastlane/metadata/ios/th/marketing_url.txt diff --git a/fastlane/metadata/ios/th/name.txt b/fastlane/metadata/ios/th/name.txt new file mode 100644 index 00000000000..1cdc33df3fb --- /dev/null +++ b/fastlane/metadata/ios/th/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin wallet diff --git a/ios/fastlane/metadata/sv/privacy_url.txt b/fastlane/metadata/ios/th/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/sv/privacy_url.txt rename to fastlane/metadata/ios/th/privacy_url.txt diff --git a/fastlane/metadata/ios/th/promotional_text.txt b/fastlane/metadata/ios/th/promotional_text.txt new file mode 100644 index 00000000000..ec81cd5849f --- /dev/null +++ b/fastlane/metadata/ios/th/promotional_text.txt @@ -0,0 +1,10 @@ +ฟีเจอร์ + +* โอเพนซอร์ส +* การเข้ารหัสเต็มรูปแบบ +* การปฏิเสธที่เป็นไปได้ +* ค่าธรรมเนียมที่ยืดหยุ่น +* Replace-By-Fee (RBF) +* SegWit +* กระเป๋าดูอย่างเดียว +* Lightning diff --git a/ios/fastlane/metadata/th/release_notes.txt b/fastlane/metadata/ios/th/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/th/release_notes.txt rename to fastlane/metadata/ios/th/release_notes.txt diff --git a/ios/fastlane/metadata/th/subtitle.txt b/fastlane/metadata/ios/th/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/th/subtitle.txt rename to fastlane/metadata/ios/th/subtitle.txt diff --git a/ios/fastlane/metadata/th/support_url.txt b/fastlane/metadata/ios/th/support_url.txt similarity index 100% rename from ios/fastlane/metadata/th/support_url.txt rename to fastlane/metadata/ios/th/support_url.txt diff --git a/fastlane/metadata/ios/tr/description.txt b/fastlane/metadata/ios/tr/description.txt new file mode 100644 index 00000000000..9e2d7ab0585 --- /dev/null +++ b/fastlane/metadata/ios/tr/description.txt @@ -0,0 +1,56 @@ +BlueWallet, radikal şekilde basit, güçlü ve güvenli bir Bitcoin cüzdanıdır. Bitcoin'inizi saklayın, gönderin ve alın — tam kontrol sizde, anahtarlarınız, paranız sizin. + +Ücretsiz ve açık kaynak, Bitcoin kullanıcıları tarafından topluluk için geliştirildi. Dünyadaki herkesle işlem yapın ve paranızı doğrudan cebinizden yönetin. Ücretsiz olarak sınırsız cüzdan oluşturun veya mevcut bir cüzdanı içe aktarın — hızlı ve basit. + +_____ + +TASARIMDAN GELEN GÜVENLİK + +Açık kaynak +MIT lisanslı — kendiniz denetleyin, derleyin ve çalıştırın. React Native ile yapıldı. + +Anahtarlarınız, paranız +Gizli anahtarlar (seed'iniz) cihazınızdan asla çıkmaz. Kontrol her zaman sizdedir. + +Çoklu İmza Kasası +Bitcoin'de mevcut en iyi güvenlik — tıpkı gerçek bir kasa gibi, harcamak için birden fazla anahtar gerekir. + +Tam şifreleme +Cihaz şifrelemesinin üzerine, her şey kendi şifrenizle yeniden şifrelenir. + +Makul ret +Kilidini açmaya zorlanmanız durumunda sahte cüzdanları açan bir tuzak şifre belirleyin. + +_____ + +GÜÇ VE KONTROL + +Modern cüzdanlar +Legacy, SegWit ve Taproot adresleri için tam destekli HD cüzdanlar. + +Donanım cüzdanları +Coldcard, Keystone ve diğer PSBT imzalayıcıları eşleştirin ve paralarınızı soğuk saklamada tutun. + +Yalnızca izleme cüzdanları +Gizli anahtarlarınızı asla açığa çıkarmadan soğuk saklamanıza göz kulak olun. + +Kendi düğümünüzü çalıştırın +Electrum üzerinden kendi Bitcoin tam düğümünüze bağlanın ve bağlantınızı Tor üzerinden yönlendirin. + +Para kontrolü +İşlemleri tam istediğiniz gibi oluşturmak için paralarınızı (UTXO'lar) görün, etiketleyin ve dondurun. + +Esnek ücretler +1 sat/vByte'a kadar kendi ücretinizi belirleyin. Takılan işlemleri RBF veya CPFP ile hızlandırın ya da iptal edin. + +_____ + +LIGHTNING NETWORK + +Sıfır yapılandırmayla haksız derecede ucuz ve son derece hızlı ödemeler. Lightning adresleri ve LNURL ile ödeyin ve ödeme alın. + +_____ + +iOS, Android ve macOS'ta kullanılabilir. Birden fazla para birimi ve dil. Karanlık mod. Hesap yok, takip yok — sadece Bitcoin. + +BlueWallet — Bitcoin'iniz size ait. \ No newline at end of file diff --git a/fastlane/metadata/ios/tr/keywords.txt b/fastlane/metadata/ios/tr/keywords.txt new file mode 100644 index 00000000000..4cba16fcf6a --- /dev/null +++ b/fastlane/metadata/ios/tr/keywords.txt @@ -0,0 +1 @@ +bitcoin,cüzdan,bitcoin cüzdanı,blockchain,btc,kripto para,lightning,segwit,çoklu imza,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/tr/name.txt b/fastlane/metadata/ios/tr/name.txt new file mode 100644 index 00000000000..dc4132c9031 --- /dev/null +++ b/fastlane/metadata/ios/tr/name.txt @@ -0,0 +1 @@ +BlueWallet - Bitcoin cüzdanı diff --git a/fastlane/metadata/ios/tr/promotional_text.txt b/fastlane/metadata/ios/tr/promotional_text.txt new file mode 100644 index 00000000000..75b0eb2a320 --- /dev/null +++ b/fastlane/metadata/ios/tr/promotional_text.txt @@ -0,0 +1,10 @@ +Özellikler + +* Açık Kaynak +* Tam şifreleme +* Makul ret +* Esnek ücretler +* Replace-By-Fee (RBF) +* SegWit +* Yalnızca izleme cüzdanları +* Lightning ağı diff --git a/ios/fastlane/metadata/trade_representative_contact_information/address_line1.txt b/fastlane/metadata/ios/trade_representative_contact_information/address_line1.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/address_line1.txt rename to fastlane/metadata/ios/trade_representative_contact_information/address_line1.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/address_line2.txt b/fastlane/metadata/ios/trade_representative_contact_information/address_line2.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/address_line2.txt rename to fastlane/metadata/ios/trade_representative_contact_information/address_line2.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/address_line3.txt b/fastlane/metadata/ios/trade_representative_contact_information/address_line3.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/address_line3.txt rename to fastlane/metadata/ios/trade_representative_contact_information/address_line3.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/city_name.txt b/fastlane/metadata/ios/trade_representative_contact_information/city_name.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/city_name.txt rename to fastlane/metadata/ios/trade_representative_contact_information/city_name.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/country.txt b/fastlane/metadata/ios/trade_representative_contact_information/country.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/country.txt rename to fastlane/metadata/ios/trade_representative_contact_information/country.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/email_address.txt b/fastlane/metadata/ios/trade_representative_contact_information/email_address.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/email_address.txt rename to fastlane/metadata/ios/trade_representative_contact_information/email_address.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/first_name.txt b/fastlane/metadata/ios/trade_representative_contact_information/first_name.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/first_name.txt rename to fastlane/metadata/ios/trade_representative_contact_information/first_name.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/is_displayed_on_app_store.txt b/fastlane/metadata/ios/trade_representative_contact_information/is_displayed_on_app_store.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/is_displayed_on_app_store.txt rename to fastlane/metadata/ios/trade_representative_contact_information/is_displayed_on_app_store.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/last_name.txt b/fastlane/metadata/ios/trade_representative_contact_information/last_name.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/last_name.txt rename to fastlane/metadata/ios/trade_representative_contact_information/last_name.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/phone_number.txt b/fastlane/metadata/ios/trade_representative_contact_information/phone_number.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/phone_number.txt rename to fastlane/metadata/ios/trade_representative_contact_information/phone_number.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/postal_code.txt b/fastlane/metadata/ios/trade_representative_contact_information/postal_code.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/postal_code.txt rename to fastlane/metadata/ios/trade_representative_contact_information/postal_code.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/state.txt b/fastlane/metadata/ios/trade_representative_contact_information/state.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/state.txt rename to fastlane/metadata/ios/trade_representative_contact_information/state.txt diff --git a/ios/fastlane/metadata/trade_representative_contact_information/trade_name.txt b/fastlane/metadata/ios/trade_representative_contact_information/trade_name.txt similarity index 100% rename from ios/fastlane/metadata/trade_representative_contact_information/trade_name.txt rename to fastlane/metadata/ios/trade_representative_contact_information/trade_name.txt diff --git a/fastlane/metadata/ios/vi/description.txt b/fastlane/metadata/ios/vi/description.txt new file mode 100644 index 00000000000..f6a7f2d86e6 --- /dev/null +++ b/fastlane/metadata/ios/vi/description.txt @@ -0,0 +1,56 @@ +BlueWallet là một ví Bitcoin cực kỳ đơn giản, mạnh mẽ và an toàn. Lưu trữ, gửi và nhận Bitcoin trong khi vẫn nắm toàn quyền kiểm soát — khóa của bạn, coin của bạn. + +Miễn phí và mã nguồn mở, được tạo bởi người dùng Bitcoin cho cộng đồng. Giao dịch với bất kỳ ai trên thế giới và làm chủ tiền của bạn, ngay từ trong túi của bạn. Tạo không giới hạn số ví miễn phí hoặc nhập một ví hiện có — nhanh chóng và đơn giản. + +_____ + +BẢO MẬT THEO THIẾT KẾ + +Mã nguồn mở +Được cấp phép MIT — kiểm tra, tự xây dựng và chạy nó. Được tạo bằng React Native. + +Khóa của bạn, coin của bạn +Khóa riêng tư (seed của bạn) không bao giờ rời khỏi thiết bị. Bạn luôn nắm quyền kiểm soát. + +Két multisig +Mức bảo mật tốt nhất trên Bitcoin — cần nhiều khóa để chi tiêu, giống như một chiếc két thật. + +Mã hóa toàn diện +Bên cạnh lớp mã hóa của thiết bị, mọi thứ được mã hóa lại bằng mật khẩu của riêng bạn. + +Khả năng phủ nhận hợp lý +Đặt một mật khẩu mồi nhử để mở các ví giả nếu bạn bị buộc phải mở khóa. + +_____ + +SỨC MẠNH VÀ KIỂM SOÁT + +Ví hiện đại +Ví HD với hỗ trợ đầy đủ cho các địa chỉ Legacy, SegWit và Taproot. + +Ví phần cứng +Kết nối Coldcard, Keystone và các thiết bị ký PSBT khác, và giữ coin của bạn trong kho lưu trữ lạnh. + +Ví chỉ xem +Theo dõi kho lưu trữ lạnh của bạn mà không bao giờ để lộ các khóa riêng tư. + +Chạy node của riêng bạn +Kết nối đến full node Bitcoin của riêng bạn qua Electrum, và định tuyến kết nối của bạn qua Tor. + +Kiểm soát coin +Xem, gắn nhãn và đóng băng coin (UTXO) của bạn để tạo giao dịch đúng theo cách bạn muốn. + +Phí linh hoạt +Tự đặt phí của bạn, xuống tới 1 sat/vByte. Tăng tốc các giao dịch bị kẹt bằng RBF hoặc CPFP, hoặc hủy chúng. + +_____ + +MẠNG LIGHTNING + +Thanh toán rẻ đến bất công và nhanh như chớp mà không cần cấu hình. Thanh toán và nhận tiền bằng địa chỉ Lightning và LNURL. + +_____ + +Có sẵn trên iOS, Android và macOS. Nhiều loại tiền tệ và ngôn ngữ. Chế độ tối. Không tài khoản và không theo dõi — chỉ có Bitcoin. + +BlueWallet — sở hữu Bitcoin của bạn. \ No newline at end of file diff --git a/fastlane/metadata/ios/vi/keywords.txt b/fastlane/metadata/ios/vi/keywords.txt new file mode 100644 index 00000000000..87edaa1c8c4 --- /dev/null +++ b/fastlane/metadata/ios/vi/keywords.txt @@ -0,0 +1 @@ +bitcoin,ví,ví bitcoin,blockchain,btc,tiền điện tử,lightning,segwit,multisig,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/vi/name.txt b/fastlane/metadata/ios/vi/name.txt new file mode 100644 index 00000000000..ec6fa2c1c0f --- /dev/null +++ b/fastlane/metadata/ios/vi/name.txt @@ -0,0 +1 @@ +BlueWallet - Ví Bitcoin diff --git a/fastlane/metadata/ios/vi/promotional_text.txt b/fastlane/metadata/ios/vi/promotional_text.txt new file mode 100644 index 00000000000..29320586e53 --- /dev/null +++ b/fastlane/metadata/ios/vi/promotional_text.txt @@ -0,0 +1,10 @@ +Tính năng + +* Mã nguồn mở +* Mã hóa toàn diện +* Khả năng phủ nhận hợp lý +* Phí linh hoạt +* Replace-By-Fee (RBF) +* SegWit +* Ví chỉ xem +* Mạng Lightning diff --git a/ios/fastlane/metadata/watch_icon.jpg b/fastlane/metadata/ios/watch_icon.jpg similarity index 100% rename from ios/fastlane/metadata/watch_icon.jpg rename to fastlane/metadata/ios/watch_icon.jpg diff --git a/ios/fastlane/metadata/zh-Hans/apple_tv_privacy_policy.txt b/fastlane/metadata/ios/zh-Hans/apple_tv_privacy_policy.txt similarity index 100% rename from ios/fastlane/metadata/zh-Hans/apple_tv_privacy_policy.txt rename to fastlane/metadata/ios/zh-Hans/apple_tv_privacy_policy.txt diff --git a/fastlane/metadata/ios/zh-Hans/description.txt b/fastlane/metadata/ios/zh-Hans/description.txt new file mode 100644 index 00000000000..606262eea81 --- /dev/null +++ b/fastlane/metadata/ios/zh-Hans/description.txt @@ -0,0 +1,56 @@ +BlueWallet 是一款极致简单、强大且安全的比特币钱包。存储、发送和接收比特币,同时始终完全掌控——你的密钥,你的币。 + +免费且开源,由比特币用户为社区打造。与世界上任何人交易,随身把控自己的资金。免费创建无限数量的钱包,或导入已有钱包——既快速又简单。 + +_____ + +安全源于设计 + +开源 +采用 MIT 许可证——你可以自行审计、构建和运行。使用 React Native 打造。 + +你的密钥,你的币 +私钥(你的助记词)永不离开你的设备。你始终掌控一切。 + +多重签名金库 +比特币上可用的最佳安全方案——花费需要多个密钥,就像真正的金库一样。 + +完全加密 +在设备加密之上,一切还会用你自己的密码再次加密。 + +可合理否认 +设置一个诱饵密码,万一被迫解锁时,它会打开假钱包。 + +_____ + +强大与掌控 + +现代钱包 +HD 钱包,完整支持 Legacy、SegWit 和 Taproot 地址。 + +硬件钱包 +配对 Coldcard、Keystone 等 PSBT 签名设备,将你的币保存在冷存储中。 + +观察钱包 +在不暴露私钥的情况下随时关注你的冷存储。 + +运行你自己的节点 +通过 Electrum 连接到你自己的比特币全节点,并通过 Tor 路由你的连接。 + +选币 +查看、标记并冻结你的币(UTXO),完全按你想要的方式构建交易。 + +灵活的矿工费 +自定义矿工费,低至 1 聪/vByte。用 RBF 或 CPFP 加速卡住的交易,或取消它们。 + +_____ + +闪电网络 + +零配置即可享受超低费用和极速付款。使用 Lightning 地址和 LNURL 付款与收款。 + +_____ + +支持 iOS、Android 和 macOS。多种货币和语言。深色模式。无账户、无追踪——只有比特币。 + +BlueWallet——掌握你自己的比特币。 \ No newline at end of file diff --git a/fastlane/metadata/ios/zh-Hans/keywords.txt b/fastlane/metadata/ios/zh-Hans/keywords.txt new file mode 100644 index 00000000000..db33866223f --- /dev/null +++ b/fastlane/metadata/ios/zh-Hans/keywords.txt @@ -0,0 +1 @@ +比特币,钱包,比特币钱包,区块链,btc,加密货币,闪电网络,segwit,多重签名,electrum \ No newline at end of file diff --git a/ios/fastlane/metadata/th/marketing_url.txt b/fastlane/metadata/ios/zh-Hans/marketing_url.txt similarity index 100% rename from ios/fastlane/metadata/th/marketing_url.txt rename to fastlane/metadata/ios/zh-Hans/marketing_url.txt diff --git a/fastlane/metadata/ios/zh-Hans/name.txt b/fastlane/metadata/ios/zh-Hans/name.txt new file mode 100644 index 00000000000..e883251c2c2 --- /dev/null +++ b/fastlane/metadata/ios/zh-Hans/name.txt @@ -0,0 +1 @@ +BlueWallet-比特幣錢包 diff --git a/ios/fastlane/metadata/th/privacy_url.txt b/fastlane/metadata/ios/zh-Hans/privacy_url.txt similarity index 100% rename from ios/fastlane/metadata/th/privacy_url.txt rename to fastlane/metadata/ios/zh-Hans/privacy_url.txt diff --git a/fastlane/metadata/ios/zh-Hans/promotional_text.txt b/fastlane/metadata/ios/zh-Hans/promotional_text.txt new file mode 100644 index 00000000000..351a6971a9f --- /dev/null +++ b/fastlane/metadata/ios/zh-Hans/promotional_text.txt @@ -0,0 +1,10 @@ +特性 + +* 开源 +* 完全加密 +* 可合理否认 +* 灵活矿工费 +* 费用替换(RBF) +* SegWit +* 观察钱包 +* 闪电网络 diff --git a/ios/fastlane/metadata/zh-Hans/release_notes.txt b/fastlane/metadata/ios/zh-Hans/release_notes.txt similarity index 100% rename from ios/fastlane/metadata/zh-Hans/release_notes.txt rename to fastlane/metadata/ios/zh-Hans/release_notes.txt diff --git a/ios/fastlane/metadata/zh-Hans/subtitle.txt b/fastlane/metadata/ios/zh-Hans/subtitle.txt similarity index 100% rename from ios/fastlane/metadata/zh-Hans/subtitle.txt rename to fastlane/metadata/ios/zh-Hans/subtitle.txt diff --git a/ios/fastlane/metadata/zh-Hans/support_url.txt b/fastlane/metadata/ios/zh-Hans/support_url.txt similarity index 100% rename from ios/fastlane/metadata/zh-Hans/support_url.txt rename to fastlane/metadata/ios/zh-Hans/support_url.txt diff --git a/fastlane/metadata/ios/zh-Hant/description.txt b/fastlane/metadata/ios/zh-Hant/description.txt new file mode 100644 index 00000000000..9acc76a40cb --- /dev/null +++ b/fastlane/metadata/ios/zh-Hant/description.txt @@ -0,0 +1,56 @@ +BlueWallet 是一款極致簡單、強大且安全的比特幣錢包。在完全掌控的前提下存放、傳送與接收比特幣——你的金鑰,你的比特幣。 + +免費且開放原始碼,由比特幣使用者為社群打造。與世界上任何人交易,從口袋裡直接掌控你的金錢。免費建立無限多個錢包,或匯入既有的錢包——既快速又簡單。 + +_____ + +設計即安全 + +開放原始碼 +採用 MIT 授權——你可以自行稽核、建置並執行。以 React Native 打造。 + +你的金鑰,你的比特幣 +私鑰(你的種子)永遠不會離開你的裝置。一切由你掌控。 + +多重簽章保管庫 +比特幣上最佳的安全防護——花費需要多把金鑰,就像真正的保管庫一樣。 + +完整加密 +在裝置加密之上,所有內容都會再以你自己的密碼加密一次。 + +合理推諉 +設定一組誘餌密碼,萬一你被迫解鎖時,它會開啟假的錢包。 + +_____ + +強大與掌控 + +現代化錢包 +HD 錢包,完整支援 Legacy、SegWit 與 Taproot 地址。 + +硬體錢包 +配對 Coldcard、Keystone 及其他 PSBT 簽署裝置,將你的比特幣保存在冷儲存中。 + +僅觀察錢包 +隨時留意你的冷儲存,而不必暴露你的私鑰。 + +執行你自己的節點 +透過 Electrum 連接到你自己的比特幣全節點,並讓連線經由 Tor 路由。 + +幣的控制 +檢視、標記並凍結你的幣(UTXO),完全依照你想要的方式建立交易。 + +彈性手續費 +自訂手續費,最低可至 1 sat/vByte。以 RBF 或 CPFP 加速卡住的交易,或將其取消。 + +_____ + +閃電網路 + +零設定即享極度便宜且飛快的支付。使用 Lightning 地址與 LNURL 付款與收款。 + +_____ + +支援 iOS、Android 與 macOS。多種貨幣與語言。深色模式。沒有帳戶、不做追蹤——只有比特幣。 + +BlueWallet——擁有你的比特幣。 \ No newline at end of file diff --git a/fastlane/metadata/ios/zh-Hant/keywords.txt b/fastlane/metadata/ios/zh-Hant/keywords.txt new file mode 100644 index 00000000000..171f6c924e5 --- /dev/null +++ b/fastlane/metadata/ios/zh-Hant/keywords.txt @@ -0,0 +1 @@ +比特幣,錢包,比特幣錢包,區塊鏈,btc,加密貨幣,lightning,segwit,多重簽章,electrum \ No newline at end of file diff --git a/fastlane/metadata/ios/zh-Hant/name.txt b/fastlane/metadata/ios/zh-Hant/name.txt new file mode 100644 index 00000000000..974e66b92d4 --- /dev/null +++ b/fastlane/metadata/ios/zh-Hant/name.txt @@ -0,0 +1 @@ +BlueWallet - 比特幣錢包 diff --git a/fastlane/metadata/ios/zh-Hant/promotional_text.txt b/fastlane/metadata/ios/zh-Hant/promotional_text.txt new file mode 100644 index 00000000000..8757fd08042 --- /dev/null +++ b/fastlane/metadata/ios/zh-Hant/promotional_text.txt @@ -0,0 +1,10 @@ +功能特色 + +* 開放原始碼 +* 完整加密 +* 合理推諉 +* 彈性手續費 +* 以新交易取代手續費(RBF) +* SegWit +* 僅觀察錢包 +* 閃電網路 diff --git a/gesture-handler.js b/gesture-handler.js new file mode 100644 index 00000000000..d76ad9a20f2 --- /dev/null +++ b/gesture-handler.js @@ -0,0 +1 @@ +// Don't import react-native-gesture-handler on web diff --git a/gesture-handler.native.js b/gesture-handler.native.js new file mode 100644 index 00000000000..d025d51ef56 --- /dev/null +++ b/gesture-handler.native.js @@ -0,0 +1,2 @@ +// Only import react-native-gesture-handler on native platforms +import 'react-native-gesture-handler'; diff --git a/helpers/confirm.ts b/helpers/confirm.ts index 2aa49c33dd8..fefecb64962 100644 --- a/helpers/confirm.ts +++ b/helpers/confirm.ts @@ -10,7 +10,7 @@ import loc from '../loc'; * * @return {Promise} */ -module.exports = function (title = 'Are you sure?', text = ''): Promise { +export default function (title = 'Are you sure?', text = ''): Promise { return new Promise(resolve => { Alert.alert( title, @@ -30,4 +30,4 @@ module.exports = function (title = 'Are you sure?', text = ''): Promise { cancelable: false }, ); }); -}; +} diff --git a/helpers/lndHub.ts b/helpers/lndHub.ts new file mode 100644 index 00000000000..e7489b0fb04 --- /dev/null +++ b/helpers/lndHub.ts @@ -0,0 +1,49 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import DefaultPreference from 'react-native-default-preference'; +import { BlueApp } from '../class/blue-app'; +import { GROUP_IO_BLUEWALLET } from '../blue_modules/currency'; + +// Function to get the value from DefaultPreference first, then fallback to AsyncStorage +// as DefaultPreference uses truly native storage. +// If found in AsyncStorage, migrate it to DefaultPreference and remove it from AsyncStorage. +export const getLNDHub = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + let value = await DefaultPreference.get(BlueApp.LNDHUB) as string | null; + + // If not found, check AsyncStorage and migrate it to DefaultPreference + if (!value) { + value = await AsyncStorage.getItem(BlueApp.LNDHUB); + + if (value) { + await DefaultPreference.set(BlueApp.LNDHUB, value); + await AsyncStorage.removeItem(BlueApp.LNDHUB); + console.log('Migrated LNDHub value from AsyncStorage to DefaultPreference'); + } + } + + return value ?? undefined; + } catch (error) { + console.error('Error getting LNDHub preference:', (error as Error).message); + return undefined; + } +}; + +export const setLNDHub = async (value: string): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.set(BlueApp.LNDHUB, value); + } catch (error) { + console.error('Error setting LNDHub preference:', error); + } +}; + +export const clearLNDHub = async (): Promise => { + try { + await DefaultPreference.setName(GROUP_IO_BLUEWALLET); + await DefaultPreference.clear(BlueApp.LNDHUB); + await AsyncStorage.removeItem(BlueApp.LNDHUB); + } catch (error) { + console.error('Error clearing LNDHub preference:', error); + } +}; \ No newline at end of file diff --git a/helpers/presentWalletExportReminder.ts b/helpers/presentWalletExportReminder.ts new file mode 100644 index 00000000000..a68bab05daa --- /dev/null +++ b/helpers/presentWalletExportReminder.ts @@ -0,0 +1,17 @@ +import { Alert } from 'react-native'; +import loc from '../loc'; + +export const presentWalletExportReminder = (): Promise => { + return new Promise((resolve, reject) => { + Alert.alert( + loc.wallets.details_title, + loc.pleasebackup.ask, + [ + { text: loc.pleasebackup.ask_yes, onPress: () => resolve(), style: 'default' }, + { text: loc.pleasebackup.ask_no, onPress: () => reject(new Error('User has denied saving the wallet backup.')) }, + { text: loc._.cancel, style: 'cancel' }, + ], + { cancelable: true }, + ); + }); +}; diff --git a/helpers/prompt.ts b/helpers/prompt.ts index 1d3c105b58f..be1642498ba 100644 --- a/helpers/prompt.ts +++ b/helpers/prompt.ts @@ -2,14 +2,18 @@ import { Platform } from 'react-native'; import prompt from 'react-native-prompt-android'; import loc from '../loc'; -module.exports = ( - title: string, - text: string, - isCancelable = true, - type: PromptType | PromptTypeIOS | PromptTypeAndroid = 'secure-text', - isOKDestructive = false, - continueButtonText = loc._.ok, -): Promise => { +type PromptHelperOptions = { + cancelable?: boolean; + type?: PromptType | PromptTypeIOS | PromptTypeAndroid; + destructive?: boolean; // applies only to the cancelable (two-button) layout + continueButtonText?: string; + defaultValue?: string; +}; + +export default (title: string, text: string, options: PromptHelperOptions = {}): Promise => { + const { cancelable = true, destructive = false, continueButtonText = loc._.ok, defaultValue } = options; + let { type = 'secure-text' } = options; + const keyboardType = type === 'numeric' ? 'numeric' : 'default'; if (Platform.OS === 'ios' && type === 'numeric') { @@ -18,7 +22,7 @@ module.exports = ( } return new Promise((resolve, reject) => { - const buttons: Array = isCancelable + const buttons: Array = cancelable ? [ { text: loc._.cancel, @@ -33,7 +37,7 @@ module.exports = ( console.log('OK Pressed'); resolve(password); }, - style: isOKDestructive ? 'destructive' : 'default', + style: destructive ? 'destructive' : 'default', }, ] : [ @@ -46,11 +50,12 @@ module.exports = ( }, ]; - prompt(title, text, buttons, { + const message = defaultValue !== undefined ? '' : text; + prompt(title, message, buttons, { type, - cancelable: isCancelable, - // @ts-ignore suppressed because its supported only on ios and is absent from type definitions + cancelable, keyboardType, + ...(defaultValue !== undefined && { defaultValue }), }); }); }; diff --git a/helpers/scan-qr.ts b/helpers/scan-qr.ts index 433bbed633c..a638acca891 100644 --- a/helpers/scan-qr.ts +++ b/helpers/scan-qr.ts @@ -1,39 +1,37 @@ -/** - * Helper function that navigates to ScanQR screen, and returns promise that will resolve with the result of a scan, - * and then navigates back. If QRCode scan was closed, promise resolves to null. - * - * @param navigateFunc {function} - * @param currentScreenName {string} - * @param showFileImportButton {boolean} - * - * @return {Promise} - */ -module.exports = function scanQrHelper( - navigateFunc: (scr: string, params?: any) => void, - currentScreenName: string, - showFileImportButton = true, -): Promise { - return new Promise(resolve => { - const params = { - showFileImportButton: Boolean(showFileImportButton), - onBarScanned: (data: any) => {}, - onDismiss: () => {}, - }; +import { Platform } from 'react-native'; +import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions'; +import { navigationRef } from '../NavigationService.ts'; + +let scanWasBBQR = false; - params.onBarScanned = function (data: any) { - setTimeout(() => resolve(data.data || data), 1); - navigateFunc(currentScreenName); - }; +const isCameraAuthorizationStatusGranted = async () => { + const status = await check(Platform.OS === 'android' ? PERMISSIONS.ANDROID.CAMERA : PERMISSIONS.IOS.CAMERA); + return status === RESULTS.GRANTED; +}; - params.onDismiss = function () { - setTimeout(() => resolve(null), 1); - }; +const requestCameraAuthorization = () => { + return request(Platform.OS === 'android' ? PERMISSIONS.ANDROID.CAMERA : PERMISSIONS.IOS.CAMERA); +}; - navigateFunc('ScanQRCodeRoot', { - screen: 'ScanQRCode', - params, - }); +const scanQrHelper = async (): Promise => { + await requestCameraAuthorization(); + return new Promise(resolve => { + if (navigationRef.isReady()) { + navigationRef.navigate('ScanQRCode', { + showFileImportButton: true, + onBarScanned: (data: string, useBBQR: true) => { + // this is not a flag of most recent BBQR format, its a flag if in a lifetime or app there was a BBQR scan + scanWasBBQR = scanWasBBQR || useBBQR; + resolve(data); + }, + }); + } }); }; -export {}; +const getScanWasBBQR = () => scanWasBBQR; +const resetScanWasBBQR = () => { + scanWasBBQR = false; +}; + +export { isCameraAuthorizationStatusGranted, requestCameraAuthorization, scanQrHelper, getScanWasBBQR, resetScanWasBBQR }; diff --git a/helpers/select-wallet.ts b/helpers/select-wallet.ts index 4838eac007a..2c7abb5a21d 100644 --- a/helpers/select-wallet.ts +++ b/helpers/select-wallet.ts @@ -1,48 +1,49 @@ +import { NavigationProp, ParamListBase } from '@react-navigation/native'; +import { TWallet } from '../class/wallets/types'; + /** * Helper function to select wallet. * Navigates to selector screen, and then navigates back while resolving promise with selected wallet. * - * @param navigateFunc {function} Function that does navigatino should be passed from outside + * @param navigation - navigation object, so inside helper we can navigate to selector screen and back * @param currentScreenName {string} Current screen name, so we know to what screen to get back to - * @param chainType {string} One of `Chain.` constant to be used to filter wallet pannels to show + * @param chainType {string} One of `Chain.` constant to be used to filter wallet panels to show * @param availableWallets {array} Wallets to be present in selector. If set, overrides `chainType` * @param noWalletExplanationText {string} Text that is displayed when there are no wallets to select from * - * @returns {Promise} + * @returns {Promise} */ -import { AbstractWallet } from '../class'; -module.exports = function ( - navigateFunc: (scr: string, params?: any) => void, +export default function ( + navigation: Pick, 'goBack' | 'navigate'>, currentScreenName: string, chainType: string | null, - availableWallets?: AbstractWallet[], + availableWallets?: TWallet[], noWalletExplanationText = '', -): Promise { +): Promise { return new Promise((resolve, reject) => { if (!currentScreenName) return reject(new Error('currentScreenName is not provided')); const params: { chainType: string | null; - availableWallets?: AbstractWallet[]; + availableWallets?: TWallet[]; noWalletExplanationText?: string; - onWalletSelect: (selectedWallet: AbstractWallet) => void; + onWalletSelect: (selectedWallet: TWallet) => void; } = { chainType: null, - onWalletSelect: (selectedWallet: AbstractWallet) => {}, + onWalletSelect: (selectedWallet: TWallet) => {}, }; if (chainType) params.chainType = chainType; if (availableWallets) params.availableWallets = availableWallets; if (noWalletExplanationText) params.noWalletExplanationText = noWalletExplanationText; - params.onWalletSelect = function (selectedWallet: AbstractWallet) { + params.onWalletSelect = function (selectedWallet: TWallet) { if (!selectedWallet) return; - setTimeout(() => resolve(selectedWallet), 1); - console.warn('trying to navigate back to', currentScreenName); - navigateFunc(currentScreenName); + setTimeout(() => resolve(selectedWallet), 100); + navigation.goBack(); }; - navigateFunc('SelectWallet', params); + navigation.navigate('SelectWallet', params); }); -}; +} diff --git a/hooks/context/useSettings.ts b/hooks/context/useSettings.ts new file mode 100644 index 00000000000..2af1959e0c4 --- /dev/null +++ b/hooks/context/useSettings.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { SettingsContext } from '../../components/Context/SettingsProvider'; + +export const useSettings = () => useContext(SettingsContext); diff --git a/hooks/context/useStorage.ts b/hooks/context/useStorage.ts new file mode 100644 index 00000000000..4394e3d157e --- /dev/null +++ b/hooks/context/useStorage.ts @@ -0,0 +1,4 @@ +import { useContext } from 'react'; +import { StorageContext } from '../../components/Context/StorageProvider'; + +export const useStorage = () => useContext(StorageContext); diff --git a/hooks/useAnimateOnChange.ts b/hooks/useAnimateOnChange.ts new file mode 100644 index 00000000000..70b648b6555 --- /dev/null +++ b/hooks/useAnimateOnChange.ts @@ -0,0 +1,37 @@ +import { useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming, Easing, interpolate } from 'react-native-reanimated'; + +const useAnimateOnChange = (value: T) => { + const progress = useSharedValue(1); + + useAnimatedReaction( + () => { + return value; + }, + (current, previous) => { + if (previous === null || previous === undefined) { + return; + } + + if (current !== previous) { + progress.value = 0; + progress.value = withTiming(1, { duration: 220, easing: Easing.out(Easing.quad) }); + } + }, + [value], + ); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(progress.value, [0, 1], [0.85, 1]), + transform: [ + { + scale: interpolate(progress.value, [0, 1], [0.98, 1]), + }, + ], + }; + }); + + return animatedStyle; +}; + +export default useAnimateOnChange; diff --git a/hooks/useAppState.ts b/hooks/useAppState.ts new file mode 100644 index 00000000000..566ebe6be27 --- /dev/null +++ b/hooks/useAppState.ts @@ -0,0 +1,24 @@ +import { useState, useEffect, useRef } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; + +const useAppState = (): { currentAppState: AppStateStatus, previousAppState: AppStateStatus | null } => { + const [currentAppState, setCurrentAppState] = useState(AppState.currentState); + const previousAppState = useRef(null); + + useEffect(() => { + const handleAppStateChange = (nextAppState: AppStateStatus) => { + previousAppState.current = currentAppState; + setCurrentAppState(nextAppState); + }; + + const subscription = AppState.addEventListener('change', handleAppStateChange); + + return () => { + subscription.remove(); + }; + }, [currentAppState]); + + return { currentAppState, previousAppState: previousAppState.current }; +}; + +export default useAppState; \ No newline at end of file diff --git a/hooks/useAsyncPromise.ts b/hooks/useAsyncPromise.ts new file mode 100644 index 00000000000..4d3a4689028 --- /dev/null +++ b/hooks/useAsyncPromise.ts @@ -0,0 +1,40 @@ +import { useState, useEffect } from 'react'; + +/** + * A custom React hook that accepts a promise and returns the resolved value and any errors that occur. + * + * @template T - The type of the resolved value. + * @param {() => Promise} promiseFn - A function that returns the promise to be resolved. + * @returns {{ data: T | null, error: Error | null, loading: boolean }} - An object with the resolved data, any error, and loading state. + */ +function useAsyncPromise(promiseFn: () => Promise) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let isMounted = true; + + promiseFn() + .then(result => { + if (isMounted) { + setData(result); + setLoading(false); + } + }) + .catch((err: Error) => { + if (isMounted) { + setError(err); + setLoading(false); + } + }); + + return () => { + isMounted = false; + }; + }, [promiseFn]); + + return { data, error, loading }; +} + +export default useAsyncPromise; diff --git a/hooks/useBiometrics.ts b/hooks/useBiometrics.ts new file mode 100644 index 00000000000..da563b9ecc1 --- /dev/null +++ b/hooks/useBiometrics.ts @@ -0,0 +1,195 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Alert, Platform } from 'react-native'; +import ReactNativeBiometrics, { BiometryTypes as RNBiometryTypes } from 'react-native-biometrics'; +import RNSecureKeyStore, { ACCESSIBLE } from 'react-native-secure-key-store'; +import loc from '../loc'; +import * as NavigationService from '../NavigationService'; +import presentAlert from '../components/Alert'; +import { useStorage } from './context/useStorage'; + +const STORAGEKEY = 'Biometrics'; +const rnBiometrics = new ReactNativeBiometrics({ allowDeviceCredentials: true }); + +const FaceID = 'Face ID'; +const TouchID = 'Touch ID'; +const Biometrics = 'Biometrics'; + +const clearKeychain = async () => { + try { + console.debug('Wiping keychain'); + console.debug('Wiping key: data'); + await RNSecureKeyStore.set('data', JSON.stringify({ data: { wallets: [] } }), { + accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }); + console.debug('Wiped key: data'); + console.debug('Wiping key: data_encrypted'); + await RNSecureKeyStore.set('data_encrypted', '', { accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + console.debug('Wiped key: data_encrypted'); + console.debug('Wiping key: STORAGEKEY'); + await RNSecureKeyStore.set(STORAGEKEY, '', { accessible: ACCESSIBLE.WHEN_UNLOCKED_THIS_DEVICE_ONLY }); + console.debug('Wiped key: STORAGEKEY'); + NavigationService.reset(); + } catch (error: any) { + console.warn(error); + presentAlert({ message: error.message }); + } +}; + +const unlockWithBiometrics = async () => { + try { + const { available } = await rnBiometrics.isSensorAvailable(); + if (!available) { + return false; + } + + return new Promise(resolve => { + rnBiometrics + .simplePrompt({ promptMessage: loc.settings.biom_conf_identity }) + .then((result: { success: any }) => { + if (result.success) { + resolve(true); + } else { + console.debug('Biometrics authentication failed'); + resolve(false); + } + }) + .catch((error: Error) => { + console.debug('Biometrics authentication error'); + presentAlert({ message: error.message }); + resolve(false); + }); + }); + } catch (e: Error | any) { + console.debug('Biometrics authentication error', e); + presentAlert({ message: e.message }); + return false; + } +}; + +const showKeychainWipeAlert = () => { + if (Platform.OS === 'ios') { + Alert.alert( + loc.settings.encrypt_tstorage, + loc.settings.biom_10times, + [ + { + text: loc._.cancel, + onPress: () => { + console.debug('Cancel Pressed'); + }, + style: 'cancel', + }, + { + text: loc._.ok, + onPress: async () => { + const { available } = await rnBiometrics.isSensorAvailable(); + if (!available) { + presentAlert({ message: loc.settings.biom_no_passcode }); + return; + } + const isAuthenticated = await unlockWithBiometrics(); + if (isAuthenticated) { + Alert.alert( + loc.settings.encrypt_tstorage, + loc.settings.biom_remove_decrypt, + [ + { text: loc._.cancel, style: 'cancel' }, + { + text: loc._.ok, + style: 'destructive', + onPress: async () => await clearKeychain(), + }, + ], + { cancelable: false }, + ); + } + }, + style: 'default', + }, + ], + { cancelable: false }, + ); + } +}; + +const useBiometrics = () => { + const { getItem, setItem } = useStorage(); + const [biometricEnabled, setBiometricEnabled] = useState(false); + const [deviceBiometricType, setDeviceBiometricType] = useState<'TouchID' | 'FaceID' | 'Biometrics' | undefined>(undefined); + + useEffect(() => { + const fetchBiometricEnabledStatus = async () => { + const enabled = await isBiometricUseEnabled(); + setBiometricEnabled(enabled); + + const biometricType = await type(); + setDeviceBiometricType(biometricType); + }; + + fetchBiometricEnabledStatus(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const isDeviceBiometricCapable = useCallback(async () => { + try { + const { available } = await rnBiometrics.isSensorAvailable(); + return available; + } catch (e) { + console.debug('Biometrics isDeviceBiometricCapable failed'); + console.debug(e); + setBiometricUseEnabled(false); + } + return false; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const type = useCallback(async () => { + try { + const { available, biometryType } = await rnBiometrics.isSensorAvailable(); + if (!available) { + return undefined; + } + + return biometryType; + } catch (e) { + console.debug('Biometrics biometricType failed'); + console.debug(e); + return undefined; + } + }, []); + + const isBiometricUseEnabled = useCallback(async () => { + try { + const enabledBiometrics = await getItem(STORAGEKEY); + return !!enabledBiometrics; + } catch (_) {} + + return false; + }, [getItem]); + + const isBiometricUseCapableAndEnabled = useCallback(async () => { + const isEnabled = await isBiometricUseEnabled(); + const isCapable = await isDeviceBiometricCapable(); + return isEnabled && isCapable; + }, [isBiometricUseEnabled, isDeviceBiometricCapable]); + + const setBiometricUseEnabled = useCallback( + async (value: boolean) => { + await setItem(STORAGEKEY, value === true ? '1' : ''); + setBiometricEnabled(value); + }, + [setItem], + ); + + return { + isDeviceBiometricCapable, + deviceBiometricType, + isBiometricUseEnabled, + isBiometricUseCapableAndEnabled, + setBiometricUseEnabled, + clearKeychain, + biometricEnabled, + }; +}; + +export { FaceID, TouchID, Biometrics, RNBiometryTypes as BiometricType, useBiometrics, showKeychainWipeAlert, unlockWithBiometrics }; diff --git a/hooks/useBounceAnimation.ts b/hooks/useBounceAnimation.ts new file mode 100644 index 00000000000..38efa49072a --- /dev/null +++ b/hooks/useBounceAnimation.ts @@ -0,0 +1,26 @@ +import { useEffect, useRef } from 'react'; +import { Animated } from 'react-native'; + +const useBounceAnimation = (query: string) => { + const bounceAnim = useRef(new Animated.Value(1.0)).current; + + useEffect(() => { + if (query) { + Animated.timing(bounceAnim, { + toValue: 1.08, // Reduced from 1.2 to 1.08 for more subtle animation + duration: 150, + useNativeDriver: true, + }).start(() => { + Animated.timing(bounceAnim, { + toValue: 1.0, + duration: 150, + useNativeDriver: true, + }).start(); + }); + } + }, [bounceAnim, query]); + + return bounceAnim; +}; + +export default useBounceAnimation; diff --git a/hooks/useCompanionListeners.ts b/hooks/useCompanionListeners.ts new file mode 100644 index 00000000000..88cce711131 --- /dev/null +++ b/hooks/useCompanionListeners.ts @@ -0,0 +1,427 @@ +import { useNavigation, CommonActions } from '@react-navigation/native'; +import { useCallback, useEffect, useRef } from 'react'; +import { AppState, AppStateStatus, Linking } from 'react-native'; +import { reconcileArkBackgroundTaskResults } from '../blue_modules/arkade-background'; +import { getClipboardContent } from '../blue_modules/clipboard'; +import { updateExchangeRate } from '../blue_modules/currency'; +import triggerHapticFeedback, { HapticFeedbackTypes } from '../blue_modules/hapticFeedback'; +import { + clearStoredNotifications, + getDeliveredNotifications, + getStoredNotifications, + initializeNotifications, + removeAllDeliveredNotifications, + setApplicationIconBadgeNumber, +} from '../blue_modules/notifications'; +import { LightningCustodianWallet } from '../class/wallets/lightning-custodian-wallet'; +import { LightningArkWallet } from '../class/wallets/lightning-ark-wallet'; +import DeeplinkSchemaMatch from '../class/deeplink-schema-match'; +import loc from '../loc'; +import { Chain } from '../models/bitcoinUnits'; +import { navigationRef } from '../NavigationService'; +import ActionSheet from '../screen/ActionSheet'; +import { useStorage } from './context/useStorage'; +import { detectQRCodeInImage } from 'react-native-camera-kit-no-google'; +import RNFS from 'react-native-fs'; +import presentAlert from '../components/Alert'; +import useWidgetCommunication from './useWidgetCommunication'; +import useDeviceQuickActions from './useDeviceQuickActions'; +import useHandoffListener from './useHandoffListener'; +import useMenuElements from './useMenuElements'; + +const ClipboardContentType = Object.freeze({ + BITCOIN: 'BITCOIN', + LIGHTNING: 'LIGHTNING', +}); + +/** + * Hook that initializes all companion listeners and functionality without rendering a component + */ +const useCompanionListeners = (skipIfNotInitialized = true) => { + const { + wallets, + addWallet, + saveToDisk, + fetchAndSaveWalletTransactions, + refreshAllWalletTransactions, + setSharedCosigner, + walletsInitialized, + } = useStorage(); + const appState = useRef(AppState.currentState); + const clipboardContent = useRef(undefined); + const navigation = useNavigation(); + + // We need to call hooks unconditionally before any conditional logic + // We'll use this check inside the effects to conditionally run logic + const shouldActivateListeners = !skipIfNotInitialized || walletsInitialized; + + // Initialize other hooks regardless of activation status + // They'll handle their own conditional logic internally + useWidgetCommunication(); + useMenuElements(); + useDeviceQuickActions(); + useHandoffListener(); + + const processPushNotifications = useCallback(async () => { + if (!shouldActivateListeners) return false; + + await new Promise(resolve => setTimeout(resolve, 200)); + try { + const notifications2process = await getStoredNotifications(); + await clearStoredNotifications(); + setApplicationIconBadgeNumber(0); + + const deliveredNotifications = await getDeliveredNotifications(); + setTimeout(async () => { + try { + removeAllDeliveredNotifications(); + } catch (error) { + console.error('Failed to remove delivered notifications:', error); + } + }, 5000); + + // Process notifications + for (const payload of notifications2process) { + const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); + + console.log('processing push notification:', payload); + + // Local notification for actionable Ark swaps. Routed by walletID + // rather than address/txid because the payload is locally generated; + // see blue_modules/arkade-notifications.ts. + if (+payload.type === 100) { + const arkWallet = wallets.find(w => w.getID() === payload.walletID); + if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) { + if (wasTapped) { + navigation.navigate('WalletTransactions', { + walletID: payload.walletID, + walletType: arkWallet?.type, + }); + return true; + } + continue; + } + // Refresh swap-derived rows directly via the wallet method to + // bypass the 5-second NOP throttle in StorageProvider.fetchAndSaveWalletTransactions: + // reconcileArkBackgroundTaskResults often runs on app resume immediately + // before this handler, which would make a throttled call NOP and + // leave the synthetic row stale. + try { + await arkWallet.fetchTransactions(); + await saveToDisk(); + } catch (e: any) { + console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e); + } + + if (wasTapped) { + const arkWalletID = arkWallet.getID(); + const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`); + if (row) { + navigation.navigate('LNDViewInvoice', { invoice: row, walletID: arkWalletID }); + } else { + navigation.navigate('WalletTransactions', { walletID: arkWalletID, walletType: arkWallet.type }); + } + return true; + } + continue; + } + + let wallet; + switch (+payload.type) { + case 2: + case 3: + wallet = wallets.find(w => w.weOwnAddress(payload.address)); + break; + case 1: + case 4: + wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); + break; + } + + if (wallet) { + const walletID = wallet.getID(); + fetchAndSaveWalletTransactions(walletID); + if (wasTapped) { + if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { + navigation.navigate('WalletTransactions', { + walletID, + walletType: wallet.type, + }); + } else { + navigation.navigate('ReceiveDetails', { + walletID, + address: payload.address, + }); + } + + return true; + } + } else { + console.log('could not find wallet while processing push notification, NOP'); + } + } + + if (deliveredNotifications.length > 0) { + for (const payload of deliveredNotifications) { + const wasTapped = payload.foreground === false || (payload.foreground === true && payload.userInteraction); + + console.log('processing push notification:', payload); + + if (+payload.type === 100) { + const arkWallet = wallets.find(w => w.getID() === payload.walletID); + if (!arkWallet || !(arkWallet instanceof LightningArkWallet)) { + if (wasTapped) { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { walletID: payload.walletID, walletType: arkWallet?.type }, + }), + ); + return true; + } + continue; + } + try { + await arkWallet.fetchTransactions(); + await saveToDisk(); + } catch (e: any) { + console.warn('[useCompanionListeners] arkWallet.fetchTransactions failed:', e?.message ?? e); + } + + if (wasTapped) { + const arkWalletID = arkWallet.getID(); + const row = arkWallet.getTransactions().find(tx => tx.txid === `swap-${payload.swapId}`); + if (row) { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'LNDViewInvoice', + params: { invoice: row, walletID: arkWalletID }, + }), + ); + } else { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { walletID: arkWalletID, walletType: arkWallet.type }, + }), + ); + } + return true; + } + continue; + } + + let wallet; + switch (+payload.type) { + case 2: + case 3: + wallet = wallets.find(w => w.weOwnAddress(payload.address)); + break; + case 1: + case 4: + wallet = wallets.find(w => w.weOwnTransaction(payload.txid || payload.hash)); + break; + } + + if (wallet) { + const walletID = wallet.getID(); + fetchAndSaveWalletTransactions(walletID); + if (wasTapped) { + if (payload.type !== 3 || wallet.chain === Chain.OFFCHAIN) { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { + walletID, + walletType: wallet.type, + }, + }), + ); + } else { + navigationRef.dispatch( + CommonActions.navigate({ + name: 'ReceiveDetails', + params: { + walletID, + address: payload.address, + }, + }), + ); + } + + return true; + } + } else { + console.log('could not find wallet while processing push notification, NOP'); + } + } + } + + if (deliveredNotifications.length > 0) { + refreshAllWalletTransactions(); + } + } catch (error) { + console.error('Failed to process push notifications:', error); + } + return false; + }, [shouldActivateListeners, wallets, fetchAndSaveWalletTransactions, saveToDisk, navigation, refreshAllWalletTransactions]); + + useEffect(() => { + if (!shouldActivateListeners) return; + + initializeNotifications(processPushNotifications); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldActivateListeners]); + + const handleOpenURL = useCallback( + async (event: { url: string }): Promise => { + if (!shouldActivateListeners) return; + + try { + if (!event.url) return; + let decodedUrl: string; + try { + decodedUrl = decodeURIComponent(event.url); + } catch (e) { + console.error('Failed to decode URL, using original', e); + decodedUrl = event.url; + } + const fileName = decodedUrl.split('/').pop()?.toLowerCase() || ''; + if (/\.(jpe?g|png)$/i.test(fileName)) { + let base64: string; + try { + base64 = await RNFS.readFile(decodedUrl, 'base64'); + } catch { + base64 = await RNFS.readFile(decodedUrl.replace(/^file:\/\//, ''), 'base64'); + } + const qrValue = await detectQRCodeInImage(base64); + if (!qrValue) { + throw new Error(loc.send.qr_error_no_qrcode); + } + triggerHapticFeedback(HapticFeedbackTypes.NotificationSuccess); + DeeplinkSchemaMatch.navigationRouteFor({ url: qrValue }, (value: [string, any]) => navigationRef.navigate(...value), { + wallets, + addWallet, + saveToDisk, + setSharedCosigner, + }); + } else { + DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => navigationRef.navigate(...value), { + wallets, + addWallet, + saveToDisk, + setSharedCosigner, + }); + } + } catch (err: any) { + console.error('Error in handleOpenURL:', err); + triggerHapticFeedback(HapticFeedbackTypes.NotificationError); + presentAlert({ message: err.message || loc.send.qr_error_no_qrcode }); + } + }, + [wallets, addWallet, saveToDisk, setSharedCosigner, shouldActivateListeners], + ); + + const showClipboardAlert = useCallback( + ({ contentType }: { contentType: undefined | string }) => { + if (!shouldActivateListeners) return; + + triggerHapticFeedback(HapticFeedbackTypes.ImpactLight); + getClipboardContent().then(clipboard => { + if (!clipboard) return; + ActionSheet.showActionSheetWithOptions( + { + title: loc._.clipboard, + message: contentType === ClipboardContentType.BITCOIN ? loc.wallets.clipboard_bitcoin : loc.wallets.clipboard_lightning, + options: [loc._.cancel, loc._.continue], + cancelButtonIndex: 0, + }, + buttonIndex => { + switch (buttonIndex) { + case 0: + break; + case 1: + handleOpenURL({ url: clipboard }); + break; + } + }, + ); + }); + }, + [handleOpenURL, shouldActivateListeners], + ); + + const handleAppStateChange = useCallback( + async (nextAppState: AppStateStatus | undefined) => { + if (!shouldActivateListeners || wallets.length === 0) return; + + if ((appState.current.match(/inactive|background/) && nextAppState === 'active') || nextAppState === undefined) { + updateExchangeRate(); + const processed = await processPushNotifications(); + // Reconcile in-process Ark background task results before the + // notification-handled early return: if the background task observed + // status changes while the app was backgrounded, the affected + // wallets need a transactions refresh whether or not a notification + // also fired. + reconcileArkBackgroundTaskResults(fetchAndSaveWalletTransactions); + if (processed) return; + const clipboard = await getClipboardContent(); + if (!clipboard) return; + const isAddressFromStoredWallet = wallets.some(wallet => { + if (wallet.chain === Chain.ONCHAIN) { + return wallet.isAddressValid && wallet.isAddressValid(clipboard) && wallet.weOwnAddress(clipboard); + } else { + return (wallet as LightningCustodianWallet).isInvoiceGeneratedByWallet(clipboard) || wallet.weOwnAddress(clipboard); + } + }); + const isBitcoinAddress = DeeplinkSchemaMatch.isBitcoinAddress(clipboard); + const isLightningInvoice = DeeplinkSchemaMatch.isLightningInvoice(clipboard); + const isLNURL = DeeplinkSchemaMatch.isLnUrl(clipboard); + const isBothBitcoinAndLightning = DeeplinkSchemaMatch.isBothBitcoinAndLightning(clipboard); + if ( + !isAddressFromStoredWallet && + clipboardContent.current !== clipboard && + (isBitcoinAddress || isLightningInvoice || isLNURL || isBothBitcoinAndLightning) + ) { + let contentType; + if (isBitcoinAddress) { + contentType = ClipboardContentType.BITCOIN; + } else if (isLightningInvoice || isLNURL) { + contentType = ClipboardContentType.LIGHTNING; + } else if (isBothBitcoinAndLightning) { + contentType = ClipboardContentType.BITCOIN; + } + showClipboardAlert({ contentType }); + } + clipboardContent.current = clipboard; + } + if (nextAppState) { + appState.current = nextAppState; + } + }, + [processPushNotifications, fetchAndSaveWalletTransactions, showClipboardAlert, wallets, shouldActivateListeners], + ); + + const addListeners = useCallback(() => { + if (!shouldActivateListeners) return { urlSubscription: null, appStateSubscription: null }; + + const urlSubscription = Linking.addEventListener('url', handleOpenURL); + const appStateSubscription = AppState.addEventListener('change', handleAppStateChange); + + return { + urlSubscription, + appStateSubscription, + }; + }, [handleOpenURL, handleAppStateChange, shouldActivateListeners]); + + useEffect(() => { + const subscriptions = addListeners(); + + return () => { + subscriptions.urlSubscription?.remove?.(); + subscriptions.appStateSubscription?.remove?.(); + }; + }, [addListeners]); +}; + +export default useCompanionListeners; diff --git a/hooks/useDebounce.ts b/hooks/useDebounce.ts new file mode 100644 index 00000000000..cfedcd4b709 --- /dev/null +++ b/hooks/useDebounce.ts @@ -0,0 +1,27 @@ +import { useState, useEffect, useMemo } from 'react'; +import debounce from '../blue_modules/debounce'; + +// Overload signatures +function useDebounce any>(callback: T, delay: number): T; +function useDebounce(value: T, delay: number): T; + +function useDebounce(value: T, delay: number): T { + const isFn = typeof value === 'function'; + + const debouncedFunction = useMemo(() => { + return isFn ? debounce(value as unknown as (...args: any[]) => any, delay) : null; + }, [isFn, value, delay]); + + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (!isFn) { + const handler = setTimeout(() => setDebouncedValue(value), delay); + return () => clearTimeout(handler); + } + }, [isFn, value, delay]); + + return isFn ? (debouncedFunction as unknown as T) : debouncedValue; +} + +export default useDebounce; diff --git a/hooks/useDeviceQuickActions.ts b/hooks/useDeviceQuickActions.ts new file mode 100644 index 00000000000..fd5d9bbb5db --- /dev/null +++ b/hooks/useDeviceQuickActions.ts @@ -0,0 +1,169 @@ +import { useEffect } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { CommonActions } from '@react-navigation/native'; +import { DeviceEventEmitter, Linking, Platform } from 'react-native'; +import QuickActions, { ShortcutItem } from 'react-native-quick-actions'; +import DeeplinkSchemaMatch from '../class/deeplink-schema-match'; +import { TWallet } from '../class/wallets/types'; +import { formatBalance } from '../loc'; +import * as NavigationService from '../NavigationService'; +import { useSettings } from '../hooks/context/useSettings'; +import { useStorage } from '../hooks/context/useStorage'; + +const DeviceQuickActionsStorageKey = 'DeviceQuickActionsEnabled'; + +export async function setEnabled(enabled: boolean = true): Promise { + await AsyncStorage.setItem(DeviceQuickActionsStorageKey, JSON.stringify(enabled)); +} + +export async function getEnabled(): Promise { + try { + const isEnabled = await AsyncStorage.getItem(DeviceQuickActionsStorageKey); + if (isEnabled === null) { + await setEnabled(true); + return true; + } + return !!JSON.parse(isEnabled); + } catch { + return true; + } +} + +const useDeviceQuickActions = () => { + const { wallets, walletsInitialized, isStorageEncrypted, addWallet, saveToDisk, setSharedCosigner } = useStorage(); + const { preferredFiatCurrency, isQuickActionsEnabled } = useSettings(); + + useEffect(() => { + if (walletsInitialized) { + isStorageEncrypted() + .then(value => { + if (value) { + removeShortcuts(); + } else { + setQuickActions(); + } + }) + .catch(() => removeShortcuts()); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [wallets, walletsInitialized, preferredFiatCurrency, isStorageEncrypted]); + + useEffect(() => { + if (walletsInitialized) { + DeviceEventEmitter.addListener('quickActionShortcut', walletQuickActions); + popInitialShortcutAction() + .then(popInitialAction) + .catch(error => { + console.error('Failed to process initial quick action:', error); + }); + return () => DeviceEventEmitter.removeAllListeners('quickActionShortcut'); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [walletsInitialized]); + + useEffect(() => { + if (walletsInitialized) { + if (isQuickActionsEnabled) { + setQuickActions(); + } else { + removeShortcuts(); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isQuickActionsEnabled, walletsInitialized]); + + const popInitialShortcutAction = async (): Promise => { + const data = await QuickActions.popInitialAction(); + return data; + }; + + const popInitialAction = async (data: any): Promise => { + try { + if (data) { + const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); + if (wallet) { + NavigationService.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { + walletID: wallet.getID(), + walletType: wallet.type, + }, + }), + ); + } + } else { + const url = await Linking.getInitialURL(); + if (url && DeeplinkSchemaMatch.hasSchema(url)) { + handleOpenURL({ url }); + } + } + } catch (error) { + console.error('Failed to handle initial quick action/deeplink:', error); + } + }; + + const handleOpenURL = (event: { url: string }): void => { + DeeplinkSchemaMatch.navigationRouteFor(event, (value: [string, any]) => NavigationService.navigate(...value), { + wallets, + addWallet, + saveToDisk, + setSharedCosigner, + }); + }; + + const walletQuickActions = (data: any): void => { + const wallet = wallets.find(w => w.getID() === data.userInfo.url.split('wallet/')[1]); + if (wallet) { + NavigationService.dispatch( + CommonActions.navigate({ + name: 'WalletTransactions', + params: { + walletID: wallet.getID(), + walletType: wallet.type, + }, + }), + ); + } + }; + + const removeShortcuts = async (): Promise => { + if (Platform.OS === 'android') { + QuickActions.clearShortcutItems(); + } else { + // @ts-ignore: Fix later + QuickActions.setShortcutItems([{ type: 'EmptyWallets', title: '' }]); + } + }; + + const setQuickActions = async (): Promise => { + if (await getEnabled()) { + QuickActions.isSupported((error: null, _supported: any) => { + if (error === null) { + const shortcutItems: ShortcutItem[] = wallets.slice(0, 4).map((wallet, index) => ({ + type: 'Wallets', + title: wallet.getLabel(), + subtitle: + wallet.hideBalance || wallet.getBalance() <= 0 + ? '' + : formatBalance(Number(wallet.getBalance()), wallet.getPreferredBalanceUnit(), true), + userInfo: { + url: `bluewallet://wallet/${wallet.getID()}`, + }, + icon: Platform.select({ + android: 'quickactions', + ios: index === 0 ? 'Favorite' : 'Bookmark', + }) || 'quickactions', + })); + QuickActions.setShortcutItems(shortcutItems); + } + }); + } else { + removeShortcuts(); + } + }; + + return { popInitialAction }; +} + +export default useDeviceQuickActions; diff --git a/hooks/useDeviceQuickActions.windows.ts b/hooks/useDeviceQuickActions.windows.ts new file mode 100644 index 00000000000..f87b43d1450 --- /dev/null +++ b/hooks/useDeviceQuickActions.windows.ts @@ -0,0 +1,17 @@ + +export const DeviceQuickActionsStorageKey = 'DeviceQuickActionsEnabled'; + +export const setEnabled = (): void => {}; + +export const getEnabled = async (): Promise => { + return false; +}; + +const useDeviceQuickActions = () => { + + const popInitialAction = (): void => {}; + return { popInitialAction }; +}; + + +export default useDeviceQuickActions; diff --git a/hooks/useHandoffListener.ios.ts b/hooks/useHandoffListener.ios.ts new file mode 100644 index 00000000000..e928b7aa8f0 --- /dev/null +++ b/hooks/useHandoffListener.ios.ts @@ -0,0 +1,67 @@ +import { useNavigation } from '@react-navigation/native'; +import { useEffect, useCallback } from 'react'; +import { NativeEventEmitter } from 'react-native'; +import EventEmitterModule from '../blue_modules/NativeEventEmitter'; +import { useStorage } from '../hooks/context/useStorage'; +import { HandOffActivityType } from '../components/types'; +import { useSettings } from './context/useSettings'; + +interface UserActivityData { + activityType: HandOffActivityType; + userInfo: { + address?: string; + xpub?: string; + }; +} + +const eventEmitter = EventEmitterModule ? new NativeEventEmitter(EventEmitterModule as any) : null; + +const useHandoffListener = () => { + const { walletsInitialized } = useStorage(); + const { isHandOffUseEnabled } = useSettings(); + const { navigate } = useNavigation(); + + const handleUserActivity = useCallback( + (data: UserActivityData) => { + if (!data || !data.activityType) { + console.debug(`Invalid handoff data received: ${data ? JSON.stringify(data) : 'No data provided'}`); + return; + } + const { activityType, userInfo } = data; + const modifiedUserInfo = { ...(userInfo || {}), type: activityType }; + try { + if (activityType === HandOffActivityType.ReceiveOnchain && modifiedUserInfo.address) { + navigate('ReceiveDetails', { address: modifiedUserInfo.address, type: activityType }); + } else if (activityType === HandOffActivityType.Xpub && modifiedUserInfo.xpub) { + navigate('WalletXpub', { xpub: modifiedUserInfo.xpub, type: activityType }); + } else { + console.debug(`Unhandled or incomplete activity type/data: ${activityType}`, modifiedUserInfo); + } + } catch (error) { + console.error('Error handling user activity:', error); + } + }, + [navigate], + ); + + useEffect(() => { + if (!walletsInitialized || !isHandOffUseEnabled) return; + + const activitySubscription = eventEmitter?.addListener('onUserActivityOpen', handleUserActivity); + + if (EventEmitterModule && (EventEmitterModule as any).getMostRecentUserActivity) { + (EventEmitterModule as any) + .getMostRecentUserActivity() + .then(handleUserActivity) + .catch(() => console.debug('No valid user activity object received')); + } else { + console.debug('EventEmitter native module is not available.'); + } + + return () => { + activitySubscription?.remove(); + }; + }, [walletsInitialized, isHandOffUseEnabled, handleUserActivity]); +}; + +export default useHandoffListener; diff --git a/hooks/useHandoffListener.ts b/hooks/useHandoffListener.ts new file mode 100644 index 00000000000..f0666c853c1 --- /dev/null +++ b/hooks/useHandoffListener.ts @@ -0,0 +1,3 @@ +const useHandoffListener = () => {}; + +export default useHandoffListener; diff --git a/hooks/useKeyboard.ts b/hooks/useKeyboard.ts new file mode 100644 index 00000000000..1baf9f42f84 --- /dev/null +++ b/hooks/useKeyboard.ts @@ -0,0 +1,54 @@ +import { useState, useEffect } from 'react'; +import { Keyboard, KeyboardEvent, Platform } from 'react-native'; + +interface KeyboardInfo { + isVisible: boolean; + height: number; +} + +interface UseKeyboardProps { + onKeyboardDidShow?: () => void; + onKeyboardDidHide?: () => void; +} + +export const useKeyboard = ({ onKeyboardDidShow, onKeyboardDidHide }: UseKeyboardProps = {}): KeyboardInfo => { + const [keyboardInfo, setKeyboardInfo] = useState({ + isVisible: false, + height: 0, + }); + + useEffect(() => { + const handleKeyboardDidShow = (event: KeyboardEvent) => { + setKeyboardInfo({ + isVisible: true, + height: event.endCoordinates.height, + }); + if (onKeyboardDidShow) { + onKeyboardDidShow(); + } + }; + + const handleKeyboardDidHide = () => { + setKeyboardInfo({ + isVisible: false, + height: 0, + }); + if (onKeyboardDidHide) { + onKeyboardDidHide(); + } + }; + + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSubscription = Keyboard.addListener(showEvent, handleKeyboardDidShow); + const hideSubscription = Keyboard.addListener(hideEvent, handleKeyboardDidHide); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, [onKeyboardDidShow, onKeyboardDidHide]); + + return keyboardInfo; +}; diff --git a/hooks/useMenuElements.ios.ts b/hooks/useMenuElements.ios.ts new file mode 100644 index 00000000000..d9c923f83c7 --- /dev/null +++ b/hooks/useMenuElements.ios.ts @@ -0,0 +1,93 @@ +import { useEffect, useCallback, useRef } from 'react'; +import { NativeEventEmitter, Platform } from 'react-native'; +import MenuElementsEmitter from '../blue_modules/NativeMenuElementsEmitter'; +import { navigationRef } from '../NavigationService'; + +type MenuActionHandler = () => void; + +let eventEmitter: NativeEventEmitter | null = null; +const handlerRegistry = new Map(); + +try { + if (Platform.OS === 'ios' && MenuElementsEmitter) { + eventEmitter = new NativeEventEmitter(MenuElementsEmitter as any); + if (typeof (MenuElementsEmitter as any).sharedInstance === 'function') { + (MenuElementsEmitter as any).sharedInstance(); + } + } +} catch (error) { + console.warn('Failed to initialize menu emitter:', error); + eventEmitter = null; +} + +interface MenuElementsHook { + registerTransactionsHandler: (handler: MenuActionHandler, screenKey?: string) => boolean; + unregisterTransactionsHandler: (screenKey: string) => void; + isMenuElementsSupported: boolean; +} + +const useMenuElements = (): MenuElementsHook => { + const initialized = useRef(false); + + useEffect(() => { + if (!initialized.current && eventEmitter) { + initialized.current = true; + + eventEmitter.addListener('openSettings', () => { + if (navigationRef.isReady()) { + navigationRef.navigate('Settings'); + } + }); + + eventEmitter.addListener('addWalletMenuAction', () => { + if (navigationRef.isReady()) { + navigationRef.navigate('AddWalletRoot'); + } + }); + + eventEmitter.addListener('importWalletMenuAction', () => { + if (navigationRef.isReady()) { + navigationRef.navigate('AddWalletRoot', { screen: 'ImportWallet' }); + } + }); + + eventEmitter.addListener('reloadTransactionsMenuAction', () => { + if (!navigationRef.isReady()) return; + + const currentRoute = navigationRef.getCurrentRoute(); + if (!currentRoute) return; + + const screenName = currentRoute.name; + const params = (currentRoute.params as { walletID?: string }) || {}; + const walletID = params.walletID; + const specificKey = walletID ? `${screenName}-${walletID}` : null; + + const handler = (specificKey ? handlerRegistry.get(specificKey) : undefined) || handlerRegistry.get(screenName); + + if (typeof handler === 'function') { + handler(); + } + }); + } + }, []); + + const registerTransactionsHandler = useCallback((handler: MenuActionHandler, screenKey?: string): boolean => { + if (typeof handler !== 'function') return false; + const key = screenKey || navigationRef.current?.getCurrentRoute()?.name; + if (!key) return false; + handlerRegistry.set(key, handler); + return true; + }, []); + + const unregisterTransactionsHandler = useCallback((screenKey: string): void => { + if (screenKey) handlerRegistry.delete(screenKey); + }, []); + + return { + registerTransactionsHandler, + unregisterTransactionsHandler, + isMenuElementsSupported: !!eventEmitter, + }; +}; + +export default useMenuElements; diff --git a/hooks/useMenuElements.ts b/hooks/useMenuElements.ts new file mode 100644 index 00000000000..863fc4125ff --- /dev/null +++ b/hooks/useMenuElements.ts @@ -0,0 +1,29 @@ +import { useCallback } from 'react'; + +type MenuActionHandler = () => void; + +interface MenuElementsHook { + registerTransactionsHandler: (handler: MenuActionHandler, screenKey?: string) => boolean; + unregisterTransactionsHandler: (screenKey: string) => void; + isMenuElementsSupported: boolean; +} + +// Default implementation for platforms other than iOS +const useMenuElements = (): MenuElementsHook => { + const registerTransactionsHandler = useCallback((_handler: MenuActionHandler, _screenKey?: string): boolean => { + // Non-functional stub for non-iOS platforms + return false; + }, []); + + const unregisterTransactionsHandler = useCallback((_screenKey: string): void => { + // No-op for non-supported platforms + }, []); + + return { + registerTransactionsHandler, + unregisterTransactionsHandler, + isMenuElementsSupported: false, // Not supported on platforms other than iOS + }; +}; + +export default useMenuElements; diff --git a/hooks/useOnAppLaunch.ts b/hooks/useOnAppLaunch.ts new file mode 100644 index 00000000000..44e13b06f07 --- /dev/null +++ b/hooks/useOnAppLaunch.ts @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { TWallet } from '../class/wallets/types'; +import { useStorage } from './context/useStorage'; + +const useOnAppLaunch = () => { + const STORAGE_KEY = 'ONAPP_LAUNCH_SELECTED_DEFAULT_WALLET_KEY'; + const { wallets } = useStorage(); + + const getSelectedDefaultWallet = useCallback(async (): Promise => { + let selectedWallet: TWallet | undefined; + try { + const selectedWalletID = await AsyncStorage.getItem(STORAGE_KEY); + console.log('Selected wallet ID:', selectedWalletID); + if (selectedWalletID !== null) { + selectedWallet = wallets.find((wallet: TWallet) => wallet.getID() === selectedWalletID); + if (!selectedWallet) { + await AsyncStorage.removeItem(STORAGE_KEY); + return undefined; + } + } else { + return undefined; + } + } catch (_e) { + return undefined; + } + return selectedWallet.getID(); + }, [STORAGE_KEY, wallets]); + + const setSelectedDefaultWallet = useCallback( + async (value: string): Promise => { + await AsyncStorage.setItem(STORAGE_KEY, value); + }, + [STORAGE_KEY], + ); // No external dependencies + + const isViewAllWalletsEnabled = useCallback(async (): Promise => { + try { + const selectedDefaultWallet = await AsyncStorage.getItem(STORAGE_KEY); + return selectedDefaultWallet === '' || selectedDefaultWallet === null; + } catch (_e) { + return true; + } + }, [STORAGE_KEY]); // No external dependencies + + const setViewAllWalletsEnabled = useCallback( + async (value: boolean): Promise => { + if (!value) { + const selectedDefaultWallet = await getSelectedDefaultWallet(); + if (!selectedDefaultWallet) { + const firstWallet = wallets[0]; + await setSelectedDefaultWallet(firstWallet.getID()); + } + } else { + await AsyncStorage.setItem(STORAGE_KEY, ''); + } + }, + [STORAGE_KEY, getSelectedDefaultWallet, setSelectedDefaultWallet, wallets], + ); + + return { + isViewAllWalletsEnabled, + setViewAllWalletsEnabled, + getSelectedDefaultWallet, + setSelectedDefaultWallet, + }; +}; + +export default useOnAppLaunch; diff --git a/hooks/useScreenProtect.ts b/hooks/useScreenProtect.ts new file mode 100644 index 00000000000..e0a7411798d --- /dev/null +++ b/hooks/useScreenProtect.ts @@ -0,0 +1,26 @@ +import { CaptureProtection } from 'react-native-capture-protection'; +import { isDesktop } from '../blue_modules/environment'; +import { useCallback } from 'react'; + +export const useScreenProtect = () => { + const enableScreenProtect = useCallback(async () => { + if (isDesktop) return; + await CaptureProtection.prevent(); + }, []); + + const disableScreenProtect = useCallback(async () => { + if (isDesktop) return; + await CaptureProtection.allow(); + }, []); + + const isScreenBeingRecorded = useCallback(async () => { + if (isDesktop) return false; + return await CaptureProtection.isScreenRecording(); + }, []); + + return { + enableScreenProtect, + disableScreenProtect, + isScreenBeingRecorded, + }; +}; diff --git a/hooks/useSizeClass.ts b/hooks/useSizeClass.ts new file mode 100644 index 00000000000..5e472560327 --- /dev/null +++ b/hooks/useSizeClass.ts @@ -0,0 +1,9 @@ +import { useSizeClass as useSizeClassOriginal, SizeClass } from '../blue_modules/sizeClass'; +import type { SizeClassInfo } from '../blue_modules/sizeClass'; + +export { SizeClass }; +export type { SizeClassInfo }; + +export const useSizeClass = useSizeClassOriginal; + +export const useIsLargeScreen = useSizeClassOriginal; diff --git a/hooks/useWalletSubscribe.tsx b/hooks/useWalletSubscribe.tsx new file mode 100644 index 00000000000..98e5d2c7830 --- /dev/null +++ b/hooks/useWalletSubscribe.tsx @@ -0,0 +1,39 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useStorage } from './context/useStorage'; +import { TWallet } from '../class/wallets/types'; + +/** + * A React hook that provides a proxied wallet instance that automatically updates when new transactions are fetched. + */ +const useWalletSubscribe = (walletID: string): TWallet => { + const { wallets } = useStorage(); + + // get wallet by ID or used cached wallet + const previousWallet = useRef(undefined); + const origWallet = wallets.find(w => w.getID() === walletID) ?? previousWallet.current; + if (!origWallet) { + throw new Error(`Wallet with ID ${walletID} not found`); + } + previousWallet.current = origWallet; + + const [lastTxFetch, setLastTxFetch] = useState(origWallet.getLastTxFetch()); + + const walletProxy = useMemo(() => { + return new Proxy(origWallet, {}); + // force update when lastTxFetch changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastTxFetch, origWallet]); + + // check every second for getLastTxFetch + useEffect(() => { + const interval = setInterval(() => { + setLastTxFetch(origWallet.getLastTxFetch()); + }, 1000); + + return () => clearInterval(interval); + }, [origWallet]); + + return walletProxy; +}; + +export default useWalletSubscribe; diff --git a/hooks/useWidgetCommunication.ios.ts b/hooks/useWidgetCommunication.ios.ts new file mode 100644 index 00000000000..aa4d1599f28 --- /dev/null +++ b/hooks/useWidgetCommunication.ios.ts @@ -0,0 +1,175 @@ +import { useEffect, useRef } from 'react'; +import DefaultPreference from 'react-native-default-preference'; +import { Transaction, TWallet } from '../class/wallets/types'; +import { useSettings } from '../hooks/context/useSettings'; +import { useStorage } from '../hooks/context/useStorage'; +import { GROUP_IO_BLUEWALLET } from '../blue_modules/currency'; +import debounce from '../blue_modules/debounce'; + +enum WidgetCommunicationKeys { + AllWalletsSatoshiBalance = 'WidgetCommunicationAllWalletsSatoshiBalance', + AllWalletsLatestTransactionTime = 'WidgetCommunicationAllWalletsLatestTransactionTime', + DisplayBalanceAllowed = 'WidgetCommunicationDisplayBalanceAllowed', + LatestTransactionIsUnconfirmed = 'WidgetCommunicationLatestTransactionIsUnconfirmed', +} + +const WIDGET_ENABLED = '1'; +const WIDGET_DISABLED = '0'; +const WIDGET_CLEARED_VALUE = '0'; + +const secondsToMilliseconds = (seconds: number): number => seconds * 1000; + +DefaultPreference.setName(GROUP_IO_BLUEWALLET); + +export const isBalanceDisplayAllowed = async (): Promise => { + try { + const displayBalance = await DefaultPreference.get(WidgetCommunicationKeys.DisplayBalanceAllowed); + if (displayBalance === WIDGET_ENABLED) { + return true; + } else if (displayBalance === WIDGET_DISABLED) { + return false; + } else { + // Preference not set, initialize to enabled by default + await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_ENABLED); + return true; + } + } catch (error) { + console.error('Failed to get DisplayBalanceAllowed:', error); + return true; + } +}; + +export const setBalanceDisplayAllowed = async (allowed: boolean): Promise => { + try { + if (allowed) { + await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_ENABLED); + } else { + await DefaultPreference.set(WidgetCommunicationKeys.DisplayBalanceAllowed, WIDGET_DISABLED); + // Clear widget data immediately when disabling + await Promise.all([ + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, WIDGET_CLEARED_VALUE), + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, WIDGET_CLEARED_VALUE), + ]); + } + console.debug('setBalanceDisplayAllowed:', allowed); + } catch (error) { + console.error('Failed to set DisplayBalanceAllowed:', error); + } +}; + +export const calculateBalanceAndTransactionTime = async ( + wallets: TWallet[], + walletsInitialized: boolean, +): Promise<{ + allWalletsBalance: number; + latestTransactionTime: number | string; +}> => { + if (!walletsInitialized || !(await isBalanceDisplayAllowed())) { + return { allWalletsBalance: 0, latestTransactionTime: 0 }; + } + + const results = await Promise.allSettled( + wallets.map(async wallet => { + if (wallet.hideBalance) return { balance: 0, latestTransactionTime: 0 }; + + const balance = await wallet.getBalance(); + const transactions: Transaction[] = await wallet.getTransactions(); + const confirmedTransactions = transactions.filter(t => (t.confirmations ?? 0) > 0); + const latestTransactionTime = + confirmedTransactions.length > 0 + ? secondsToMilliseconds(Math.max(...confirmedTransactions.map(t => t.timestamp || t.time || 0))) + : WidgetCommunicationKeys.LatestTransactionIsUnconfirmed; + + return { balance, latestTransactionTime }; + }), + ); + + const allWalletsBalance = results.reduce((acc, result) => acc + (result.status === 'fulfilled' ? result.value.balance : 0), 0); + const latestTransactionTime = results.reduce( + (max, result) => + result.status === 'fulfilled' && typeof result.value.latestTransactionTime === 'number' && result.value.latestTransactionTime > max + ? result.value.latestTransactionTime + : max, + 0, + ); + + return { allWalletsBalance, latestTransactionTime }; +}; + +export const syncWidgetBalanceWithWallets = async ( + wallets: TWallet[], + walletsInitialized: boolean, + cachedBalance: { current: number }, + cachedLatestTransactionTime: { current: number | string }, +): Promise => { + try { + const { allWalletsBalance, latestTransactionTime } = await calculateBalanceAndTransactionTime(wallets, walletsInitialized); + + if (cachedBalance.current !== allWalletsBalance || cachedLatestTransactionTime.current !== latestTransactionTime) { + await Promise.all([ + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, String(allWalletsBalance)), + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, String(latestTransactionTime)), + ]); + + cachedBalance.current = allWalletsBalance; + cachedLatestTransactionTime.current = latestTransactionTime; + } + } catch (error) { + console.error('Failed to sync widget balance with wallets:', error); + } +}; + +const debouncedSyncWidgetBalanceWithWallets = debounce( + async ( + wallets: TWallet[], + walletsInitialized: boolean, + cachedBalance: { current: number }, + cachedLatestTransactionTime: { current: number | string }, + ) => { + await syncWidgetBalanceWithWallets(wallets, walletsInitialized, cachedBalance, cachedLatestTransactionTime); + }, + 500, +); + +const useWidgetCommunication = (): void => { + const { wallets, walletsInitialized } = useStorage(); + const { isWidgetBalanceDisplayAllowed } = useSettings(); + const cachedBalance = useRef(0); + const cachedLatestTransactionTime = useRef(0); + + // Handle widget data clearing when the setting is disabled + useEffect(() => { + const clearWidgetData = async () => { + if (walletsInitialized && !isWidgetBalanceDisplayAllowed) { + try { + await Promise.all([ + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsSatoshiBalance, WIDGET_CLEARED_VALUE), + DefaultPreference.set(WidgetCommunicationKeys.AllWalletsLatestTransactionTime, WIDGET_CLEARED_VALUE), + ]); + cachedBalance.current = 0; + cachedLatestTransactionTime.current = 0; + console.debug('Widget data cleared due to setting being disabled'); + } catch (error) { + console.error('Failed to clear widget data:', error); + } + } + }; + + clearWidgetData(); + }, [isWidgetBalanceDisplayAllowed, walletsInitialized]); + + // Sync widget data when wallets change or setting is enabled + useEffect(() => { + if (walletsInitialized) { + debouncedSyncWidgetBalanceWithWallets(wallets, walletsInitialized, cachedBalance, cachedLatestTransactionTime); + } + }, [wallets, walletsInitialized, isWidgetBalanceDisplayAllowed]); + + useEffect(() => { + return () => { + debouncedSyncWidgetBalanceWithWallets.cancel(); + }; + }, []); +}; + +export default useWidgetCommunication; diff --git a/hooks/useWidgetCommunication.ts b/hooks/useWidgetCommunication.ts new file mode 100644 index 00000000000..488fa01d55d --- /dev/null +++ b/hooks/useWidgetCommunication.ts @@ -0,0 +1,9 @@ +const useWidgetCommunication = (): void => {}; + +export const isBalanceDisplayAllowed = async (): Promise => { + return true; +}; + +export const setBalanceDisplayAllowed = async (_allowed: boolean): Promise => {}; + +export default useWidgetCommunication; diff --git a/img/Search/drag.tsx b/img/Search/drag.tsx new file mode 100644 index 00000000000..a40698a61f9 --- /dev/null +++ b/img/Search/drag.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Svg, { Path, Defs, ClipPath, Rect, G } from 'react-native-svg'; + +interface DragIconProps { + width?: number; + height?: number; + color?: string; +} + +const DragIcon: React.FC = ({ width = 19, height = 38, color = '#82858D' }) => ( + + + + + + + + + + +); + +export default DragIcon; diff --git a/img/addWallet/bitcoin.png b/img/addWallet/bitcoin.png index f3af4e7aea7..677b833493a 100644 Binary files a/img/addWallet/bitcoin.png and b/img/addWallet/bitcoin.png differ diff --git a/img/addWallet/bitcoin@2x.png b/img/addWallet/bitcoin@2x.png index 0540be9ca4f..67d98ea44ae 100644 Binary files a/img/addWallet/bitcoin@2x.png and b/img/addWallet/bitcoin@2x.png differ diff --git a/img/addWallet/bitcoin@3x.png b/img/addWallet/bitcoin@3x.png index 9555ad1542b..267562decbb 100644 Binary files a/img/addWallet/bitcoin@3x.png and b/img/addWallet/bitcoin@3x.png differ diff --git a/img/addWallet/lightning.png b/img/addWallet/lightning.png index 9cc3e194c4c..6fe6df59c65 100644 Binary files a/img/addWallet/lightning.png and b/img/addWallet/lightning.png differ diff --git a/img/addWallet/lightning@2x.png b/img/addWallet/lightning@2x.png index 893c82eefef..81a35744e36 100644 Binary files a/img/addWallet/lightning@2x.png and b/img/addWallet/lightning@2x.png differ diff --git a/img/addWallet/lightning@3x.png b/img/addWallet/lightning@3x.png index c70e4bfbbc6..00750555055 100644 Binary files a/img/addWallet/lightning@3x.png and b/img/addWallet/lightning@3x.png differ diff --git a/img/addWallet/vault.png b/img/addWallet/vault.png index a31b86d837e..211acea6052 100644 Binary files a/img/addWallet/vault.png and b/img/addWallet/vault.png differ diff --git a/img/addWallet/vault@2x.png b/img/addWallet/vault@2x.png index ee6e21a9bce..b15a723bfe1 100644 Binary files a/img/addWallet/vault@2x.png and b/img/addWallet/vault@2x.png differ diff --git a/img/addWallet/vault@3x.png b/img/addWallet/vault@3x.png index 34cfa360954..1a6008e8c64 100644 Binary files a/img/addWallet/vault@3x.png and b/img/addWallet/vault@3x.png differ diff --git a/img/addWallet/vault_main.png b/img/addWallet/vault_main.png index aeaa1632490..50e279a2f78 100644 Binary files a/img/addWallet/vault_main.png and b/img/addWallet/vault_main.png differ diff --git a/img/addWallet/vault_main@2x.png b/img/addWallet/vault_main@2x.png index 70e6d38ee88..e648b25c166 100644 Binary files a/img/addWallet/vault_main@2x.png and b/img/addWallet/vault_main@2x.png differ diff --git a/img/addWallet/vault_main@3x.png b/img/addWallet/vault_main@3x.png index 035205331e6..f7d16ba464f 100644 Binary files a/img/addWallet/vault_main@3x.png and b/img/addWallet/vault_main@3x.png differ diff --git a/img/bluewalletsplash.json b/img/bluewalletsplash.json deleted file mode 100644 index a4b85e2fa4e..00000000000 --- a/img/bluewalletsplash.json +++ /dev/null @@ -1 +0,0 @@ -{"ip":0,"fr":60,"v":"5.1.20","assets":[],"layers":[{"ty":4,"nm":"blue","ip":0,"st":0,"ind":5,"hix":1,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"t":88,"s":[0],"e":[100],"i":{"x":[0.515],"y":[0.955]},"o":{"x":[0.455],"y":[0.03]}},{"t":132}]},"or":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[21.5,8.5,0]},"p":{"s":true,"x":{"a":0,"k":294},"y":{"a":0,"k":231}},"rx":{"a":0,"k":0},"ry":{"a":0,"k":0},"rz":{"a":0,"k":0},"s":{"a":0,"k":[100,100]}},"shapes":[{"ty":"gr","nm":"blue shape group","it":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[71.1621094,96.1743164],[67.6757812,94.2260742],[67.4912109,94.2260742],[67.4912109,96],[64.5996094,96],[64.5996094,80.3730469],[67.5834961,80.3730469],[67.5834961,86.4946289],[67.7680664,86.4946289],[71.1621094,84.5053711],[75.7456055,90.3398438]],"i":[[2.8403319999999894,0],[0.6049804999999964,1.230468799999997],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[-1.548339900000002,0],[0,-3.6503907000000027]],"o":[[-1.5996094000000056,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0,0],[0.5742188000000112,-1.240722699999992],[2.8608397999999937,0],[0,3.6298828000000043]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[70.1264648,87.0073242],[67.5527344,90.3500977],[70.1264648,93.6826172],[72.6796875,90.3398438]],"i":[[1.5791016000000013,0],[0.010253899999995042,-2.0610352000000063],[-1.579101499999993,0],[0,2.0712890000000073]],"o":[[-1.568847599999998,0],[0.010253899999995042,2.0507811999999888],[1.5893555000000106,0],[0,-2.061035199999992]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[77.7401952,96],[77.7401952,80.3730469],[80.7240819,80.3730469],[80.7240819,96]],"i":[[0,0],[0,0],[0,0],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[93.5160349,84.6899414],[93.5160349,96],[90.6244333,96],[90.6244333,94.1850586],[90.439863,94.1850586],[87.1381052,96.2460938],[83.1800974,92.0625],[83.1800974,84.6899414],[86.1639841,84.6899414],[86.1639841,91.293457],[88.245527,93.6518555],[90.5321481,91.2114258],[90.5321481,84.6899414]],"i":[[0,0],[0,0],[0,0],[0,0],[0,0],[1.712402300000008,0],[0,2.625],[0,0],[0,0],[0,0],[-1.3740233999999987,0],[0,1.5073241999999993],[0,0]],"o":[[0,0],[0,0],[0,0],[0,0],[-0.5332031000000086,1.3125],[-2.4404296999999957,0],[0,0],[0,0],[0,0],[0,1.558593799999997],[1.4868165000000033,0],[0,0],[0,0]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[100.863164,86.7304688],[98.4022261,89.1606445],[103.221562,89.1606445]],"i":[[1.3945310000000006,0],[0.10253910000000133,-1.466308600000005],[0,0]],"o":[[-1.3842776999999984,0],[0,0],[-0.06152300000000821,-1.4970703000000043]]}}},{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[103.283085,92.7802734],[106.061894,92.7802734],[100.893925,96.2460938],[95.4183394,90.4013672],[100.85291,84.4438477],[106.154179,90.1552734],[106.154179,91.0678711],[98.3919722,91.0678711],[98.3919722,91.2216797],[100.975957,93.9492188]],"i":[[-0.3178709999999967,0.7485352000000063],[0,0],[2.7685550000000063,0],[0,3.6708983999999987],[-3.3632814999999994,0],[0,-3.5888671999999957],[0,0],[0,0],[0,0],[-1.5585941999999875,0]],"o":[[0,0],[-0.4511719999999997,2.1328125],[-3.4453121999999894,0],[0,-3.681152400000002],[3.332519000000005,0],[0,0],[0,0],[0,0],[0.04101560000000859,1.6816406000000086],[1.1791990000000112,0]]}}},{"ty":"st","o":{"a":0,"k":0},"w":{"a":0,"k":0},"c":{"a":0,"k":[0,0,0,0]},"lc":3,"lj":1,"ml":1},{"ty":"fl","o":{"a":0,"k":100},"r":1,"c":{"a":0,"k":[1,1,1,1]}},{"ty":"tr","o":{"a":0,"k":100},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[-64,-80]},"r":{"a":0,"k":0}}]}],"op":132},{"ty":4,"nm":"wallet","ip":0,"st":0,"ind":4,"hix":2,"ks":{"o":{"a":0,"k":0},"or":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[29.5,8.5,0]},"p":{"s":true,"x":{"a":0,"k":291},"y":{"a":0,"k":235.5}},"rx":{"a":0,"k":0},"ry":{"a":0,"k":0},"rz":{"a":0,"k":0},"s":{"a":0,"k":[100,100,100]}},"shapes":[],"op":132},{"ty":4,"nm":"small","ip":0,"st":0,"ind":3,"hix":3,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"e":[100],"i":{"x":[0.675],"y":[0.19]},"o":{"x":[0.55],"y":[0.055]}},{"t":40}]},"or":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[60,32,0]},"p":{"s":true,"x":{"a":0,"k":275},"y":{"a":1,"k":[{"t":0,"s":[267.5],"e":[228],"i":{"x":[0.265],"y":[1.5]},"o":{"x":[0.68],"y":[-0.55]}},{"t":40}]}},"rx":{"a":0,"k":0},"ry":{"a":0,"k":0},"rz":{"a":0,"k":0},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"gr","nm":"small shape group","it":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[30.3055543,56.484375],[89.6944457,56.484375],[106.331298,60.053101],[116.431274,70.1530769],[120,86.7899293],[120,89.6944457],[116.431274,106.331298],[106.331298,116.431274],[89.6944457,120],[30.3055543,120],[13.6687019,116.431274],[3.56872596,106.331298],[-4.39256841e-16,89.6944457],[4.39256841e-16,86.7899293],[3.56872596,70.1530769],[13.6687019,60.053101]],"i":[[-7.174513700000002,0],[0,0],[-4.354167000000004,-2.3286339],[-2.328634000000008,-4.354166599999999],[0,-7.1745136999999914],[0,0],[2.328633999999994,-4.354167000000004],[4.35416699999999,-2.328634000000008],[7.1745136999999914,0],[0,0],[4.354166699999999,2.328633999999994],[2.3286338200000003,4.35416699999999],[8.78624526e-16,7.1745136999999914],[0,0],[-2.3286338100000004,4.354166699999993],[-4.354166640000001,2.3286337999999986]],"o":[[0,0],[7.1745136999999914,0],[4.35416699999999,2.3286337999999986],[2.328633999999994,4.354166699999993],[0,0],[0,7.1745136999999914],[-2.328634000000008,4.35416699999999],[-4.354167000000004,2.328633999999994],[0,0],[-7.174513700000002,0],[-4.354166640000001,-2.328634000000008],[-2.3286338100000004,-4.354167000000004],[0,0],[-8.78624526e-16,-7.1745136999999914],[2.3286338200000003,-4.354166599999999],[4.354166699999999,-2.3286339]]}}},{"ty":"st","o":{"a":0,"k":0},"w":{"a":0,"k":0},"c":{"a":0,"k":[0,0,0,0]},"lc":3,"lj":1,"ml":1},{"ty":"gf","o":{"a":0,"k":100},"r":2,"g":{"p":2,"k":{"a":0,"k":[0,0.5450980392156862,0.8431372549019608,0.9764705882352941,1,0.40784313725490196,0.7333333333333333,0.8823529411764706]}},"t":1,"s":{"a":0,"k":[60,3.2704118520000005]},"e":{"a":0,"k":[60,120]}},{"ty":"tr","o":{"a":0,"k":100},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,-56]},"r":{"a":0,"k":0}}]}],"op":132},{"ty":4,"nm":"medium","ip":0,"st":0,"ind":2,"hix":4,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"t":27,"s":[0],"e":[100],"i":{"x":[0.675],"y":[0.19]},"o":{"x":[0.55],"y":[0.055]}},{"t":64}]},"or":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[60,47,0]},"p":{"s":true,"x":{"a":0,"k":275},"y":{"a":1,"k":[{"t":0,"s":[274],"e":[274],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"t":27,"s":[274],"e":[213],"i":{"x":[0.265],"y":[1.5]},"o":{"x":[0.68],"y":[-0.55]}},{"t":64}]}},"rx":{"a":0,"k":0},"ry":{"a":0,"k":0},"rz":{"a":0,"k":0},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"gr","nm":"medium shape group","it":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[34.2519038,26.484375],[85.7480962,26.484375],[106.331298,30.053101],[116.431274,40.1530769],[120,60.7362788],[120,85.7480962],[116.431274,106.331298],[106.331298,116.431274],[85.7480962,120],[34.2519038,120],[13.6687019,116.431274],[3.56872596,106.331298],[-9.22545274e-16,85.7480962],[9.22545274e-16,60.7362788],[3.56872596,40.1530769],[13.6687019,30.053101]],"i":[[-11.9101331,0],[0,0],[-4.354167000000004,-2.3286339000000034],[-2.328634000000008,-4.354166599999999],[0,-11.910133100000003],[0,0],[2.328633999999994,-4.354167000000004],[4.35416699999999,-2.328634000000008],[11.910133099999996,0],[0,0],[4.354166699999999,2.328633999999994],[2.3286338200000003,4.35416699999999],[1.458570646e-15,11.910133099999996],[0,0],[-2.3286338100000004,4.3541667],[-4.354166640000001,2.328633799999995]],"o":[[0,0],[11.910133099999996,0],[4.35416699999999,2.328633799999995],[2.328633999999994,4.3541667],[0,0],[0,11.910133099999996],[-2.328634000000008,4.35416699999999],[-4.354167000000004,2.328633999999994],[0,0],[-11.9101331,0],[-4.354166640000001,-2.328634000000008],[-2.3286338100000004,-4.354167000000004],[0,0],[-1.458570646e-15,-11.910133100000003],[2.3286338200000003,-4.354166599999999],[4.354166699999999,-2.3286339000000034]]}}},{"ty":"st","o":{"a":0,"k":0},"w":{"a":0,"k":0},"c":{"a":0,"k":[0,0,0,0]},"lc":3,"lj":1,"ml":1},{"ty":"gf","o":{"a":0,"k":100},"r":2,"g":{"p":2,"k":{"a":0,"k":[0,0.24705882352941178,0.47058823529411764,0.8627450980392157,1,0.1843137254901961,0.37254901960784315,0.7019607843137254]}},"t":1,"s":{"a":0,"k":[60,0]},"e":{"a":0,"k":[60,117.46116324]}},{"ty":"tr","o":{"a":0,"k":100},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,-26]},"r":{"a":0,"k":0}}]}],"op":132},{"ty":4,"nm":"big","ip":0,"st":0,"ind":1,"hix":5,"ks":{"o":{"a":1,"k":[{"t":0,"s":[0],"e":[0],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"t":49,"s":[0],"e":[100],"i":{"x":[0.675],"y":[0.19]},"o":{"x":[0.55],"y":[0.055]}},{"t":88}]},"or":{"a":0,"k":[0,0,0]},"a":{"a":0,"k":[60,60,0]},"p":{"s":true,"x":{"a":0,"k":275},"y":{"a":1,"k":[{"t":0,"s":[287],"e":[287],"i":{"x":[1],"y":[1]},"o":{"x":[0],"y":[0]}},{"t":49,"s":[287],"e":[200],"i":{"x":[0.265],"y":[1.5]},"o":{"x":[0.68],"y":[-0.55]}},{"t":88}]}},"rx":{"a":0,"k":0},"ry":{"a":0,"k":0},"rz":{"a":0,"k":0},"s":{"a":0,"k":[100,100,100]}},"shapes":[{"ty":"gr","nm":"big shape group","it":[{"ty":"sh","ks":{"a":0,"k":{"c":true,"v":[[34.2519038,-1.38381791e-15],[85.7480962,1.38381791e-15],[106.331298,3.56872596],[116.431274,13.6687019],[120,34.2519038],[120,85.7480962],[116.431274,106.331298],[106.331298,116.431274],[85.7480962,120],[34.2519038,120],[13.6687019,116.431274],[3.56872596,106.331298],[-9.22545274e-16,85.7480962],[9.22545274e-16,34.2519038],[3.56872596,13.6687019],[13.6687019,3.56872596]],"i":[[-11.9101331,2.187855967e-15],[0,0],[-4.354167000000004,-2.3286338100000004],[-2.328634000000008,-4.354166640000001],[0,-11.9101331],[0,0],[2.328633999999994,-4.354167000000004],[4.35416699999999,-2.328634000000008],[11.910133099999996,0],[0,0],[4.354166699999999,2.328633999999994],[2.3286338200000003,4.35416699999999],[1.458570646e-15,11.910133099999996],[0,0],[-2.3286338100000004,4.354166699999999],[-4.354166640000001,2.3286338200000003]],"o":[[0,0],[11.910133099999996,-2.187855967e-15],[4.35416699999999,2.3286338200000003],[2.328633999999994,4.354166699999999],[0,0],[0,11.910133099999996],[-2.328634000000008,4.35416699999999],[-4.354167000000004,2.328633999999994],[0,0],[-11.9101331,0],[-4.354166640000001,-2.328634000000008],[-2.3286338100000004,-4.354167000000004],[0,0],[-1.458570646e-15,-11.9101331],[2.3286338200000003,-4.354166640000001],[4.354166699999999,-2.3286338100000004]]}}},{"ty":"st","o":{"a":0,"k":0},"w":{"a":0,"k":0},"c":{"a":0,"k":[0,0,0,0]},"lc":3,"lj":1,"ml":1},{"ty":"gf","o":{"a":0,"k":100},"r":2,"g":{"p":2,"k":{"a":0,"k":[0,0.09019607843137255,0.27450980392156865,0.592156862745098,1,0.047058823529411764,0.1450980392156863,0.3137254901960784]}},"t":1,"s":{"a":0,"k":[60,3.405888732]},"e":{"a":0,"k":[60,120]}},{"ty":"tr","o":{"a":0,"k":100},"a":{"a":0,"k":[0,0]},"s":{"a":0,"k":[100,100]},"p":{"a":0,"k":[0,0]},"r":{"a":0,"k":0}}]}],"op":132}],"op":132,"w":550,"h":400} \ No newline at end of file diff --git a/img/btc-shape-rtl.png b/img/btc-shape-rtl.png index a1e01957114..9ff0b0abb6c 100644 Binary files a/img/btc-shape-rtl.png and b/img/btc-shape-rtl.png differ diff --git a/img/btc-shape.png b/img/btc-shape.png index 943fa2e38b7..ffdc7216312 100644 Binary files a/img/btc-shape.png and b/img/btc-shape.png differ diff --git a/img/close-white.png b/img/close-white.png index cb86897ea0a..d0b88f33c51 100644 Binary files a/img/close-white.png and b/img/close-white.png differ diff --git a/img/close-white@2x.png b/img/close-white@2x.png index ef648c6b705..b53e5fbb4f3 100644 Binary files a/img/close-white@2x.png and b/img/close-white@2x.png differ diff --git a/img/close-white@3x.png b/img/close-white@3x.png index 46c13afb9cf..ec04a1a0acb 100644 Binary files a/img/close-white@3x.png and b/img/close-white@3x.png differ diff --git a/img/close.png b/img/close.png old mode 100755 new mode 100644 index 5b2e38c880d..cfb7a807f6c Binary files a/img/close.png and b/img/close.png differ diff --git a/img/close@2x.png b/img/close@2x.png old mode 100755 new mode 100644 index 6f8179fd831..8f82ffdea82 Binary files a/img/close@2x.png and b/img/close@2x.png differ diff --git a/img/close@3x.png b/img/close@3x.png old mode 100755 new mode 100644 index 0532d6c5f24..3e4c2227e18 Binary files a/img/close@3x.png and b/img/close@3x.png differ diff --git a/img/faceid-dark.png b/img/faceid-dark.png deleted file mode 100644 index ff50919d759..00000000000 Binary files a/img/faceid-dark.png and /dev/null differ diff --git a/img/faceid-default.png b/img/faceid-default.png deleted file mode 100644 index 5a145828662..00000000000 Binary files a/img/faceid-default.png and /dev/null differ diff --git a/img/hodlhodl-default-avatar.png b/img/hodlhodl-default-avatar.png deleted file mode 100644 index 590a2d5ecbb..00000000000 Binary files a/img/hodlhodl-default-avatar.png and /dev/null differ diff --git a/img/icon.png b/img/icon.png index ec541528a9a..4c4d57e85fb 100644 Binary files a/img/icon.png and b/img/icon.png differ diff --git a/img/icon@2x.png b/img/icon@2x.png index a72c80addc6..23c1109bac5 100644 Binary files a/img/icon@2x.png and b/img/icon@2x.png differ diff --git a/img/icon@3x.png b/img/icon@3x.png index 5727bf33172..84755259c91 100644 Binary files a/img/icon@3x.png and b/img/icon@3x.png differ diff --git a/img/lnd-shape-rtl.png b/img/lnd-shape-rtl.png index 000a0188965..5c860c38c5a 100644 Binary files a/img/lnd-shape-rtl.png and b/img/lnd-shape-rtl.png differ diff --git a/img/lnd-shape.png b/img/lnd-shape.png index 5af49ac2111..d0369712158 100644 Binary files a/img/lnd-shape.png and b/img/lnd-shape.png differ diff --git a/img/mshelp/mshelp-intro.png b/img/mshelp/mshelp-intro.png index 88c40f9909c..d40e7e17007 100644 Binary files a/img/mshelp/mshelp-intro.png and b/img/mshelp/mshelp-intro.png differ diff --git a/img/mshelp/mshelp-intro@2x.png b/img/mshelp/mshelp-intro@2x.png index 3705d7b7571..b476a336d6b 100644 Binary files a/img/mshelp/mshelp-intro@2x.png and b/img/mshelp/mshelp-intro@2x.png differ diff --git a/img/mshelp/mshelp-intro@3x.png b/img/mshelp/mshelp-intro@3x.png index f8128d54dd1..472762eda79 100644 Binary files a/img/mshelp/mshelp-intro@3x.png and b/img/mshelp/mshelp-intro@3x.png differ diff --git a/img/mshelp/tip2.png b/img/mshelp/tip2.png index 02e95435d46..3549728109f 100644 Binary files a/img/mshelp/tip2.png and b/img/mshelp/tip2.png differ diff --git a/img/mshelp/tip2@2x.png b/img/mshelp/tip2@2x.png index e6951fd0661..894d73546c6 100644 Binary files a/img/mshelp/tip2@2x.png and b/img/mshelp/tip2@2x.png differ diff --git a/img/mshelp/tip2@3x.png b/img/mshelp/tip2@3x.png index 1ff9c239dba..98432ce83a3 100644 Binary files a/img/mshelp/tip2@3x.png and b/img/mshelp/tip2@3x.png differ diff --git a/img/mshelp/tip3.png b/img/mshelp/tip3.png index 8a952f7a049..27252ba519f 100644 Binary files a/img/mshelp/tip3.png and b/img/mshelp/tip3.png differ diff --git a/img/mshelp/tip3@2x.png b/img/mshelp/tip3@2x.png index 50d1cdc87bb..c511d1cc194 100644 Binary files a/img/mshelp/tip3@2x.png and b/img/mshelp/tip3@2x.png differ diff --git a/img/mshelp/tip3@3x.png b/img/mshelp/tip3@3x.png index 0d21bf37b37..2c8bf89e461 100644 Binary files a/img/mshelp/tip3@3x.png and b/img/mshelp/tip3@3x.png differ diff --git a/img/mshelp/tip4.png b/img/mshelp/tip4.png index f9744c9fde1..e8445152d3e 100644 Binary files a/img/mshelp/tip4.png and b/img/mshelp/tip4.png differ diff --git a/img/mshelp/tip4@2x.png b/img/mshelp/tip4@2x.png index e402e11c780..8fcc0f7665b 100644 Binary files a/img/mshelp/tip4@2x.png and b/img/mshelp/tip4@2x.png differ diff --git a/img/mshelp/tip4@3x.png b/img/mshelp/tip4@3x.png index 9964ff4f8bf..fd71e153b61 100644 Binary files a/img/mshelp/tip4@3x.png and b/img/mshelp/tip4@3x.png differ diff --git a/img/mshelp/tip5.png b/img/mshelp/tip5.png index 7e2e1fa40ce..2b0827295f0 100644 Binary files a/img/mshelp/tip5.png and b/img/mshelp/tip5.png differ diff --git a/img/mshelp/tip5@2x.png b/img/mshelp/tip5@2x.png index 50d66de1206..0117a052e25 100644 Binary files a/img/mshelp/tip5@2x.png and b/img/mshelp/tip5@2x.png differ diff --git a/img/mshelp/tip5@3x.png b/img/mshelp/tip5@3x.png index 046ebf19b81..265d37dd9eb 100644 Binary files a/img/mshelp/tip5@3x.png and b/img/mshelp/tip5@3x.png differ diff --git a/img/pending.json b/img/pending.json new file mode 100644 index 00000000000..6b4db9cd648 --- /dev/null +++ b/img/pending.json @@ -0,0 +1 @@ +{"v":"5.7.5","fr":100,"ip":0,"op":300,"w":16,"h":16,"ddd":0,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"lottielab.com","sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"v":[[0,5.4],[6.17,0]],"i":[[0,0],[-2.057,1.799]],"o":[[2.057,-1.799],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[12,13.51],"ix":2},"a":{"a":0,"k":[0,5.4],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":300,"s":[440]}],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"v":[[0,6.83],[9.65,0]],"i":[[0,0],[-3.217,2.278]],"o":[[3.217,-2.278],[0,0]]}}},{"ty":"tr","p":{"a":0,"k":[12,13.51],"ix":2},"a":{"a":0,"k":[0,6.83],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":300,"s":[720]}],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"fl","c":{"a":0,"k":[0.153,0.342,0.78],"ix":2},"o":{"a":0,"k":100,"ix":2},"r":1,"bm":0},{"ty":"tr","o":{"a":0,"k":100,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"v":[[0,5.4],[6.17,0]],"i":[[0,0],[-2.057,1.799]],"o":[[2.057,-1.799],[0,0]]}}},{"ty":"st","c":{"a":0,"k":[0.153,0.342,0.78],"ix":2},"o":{"a":0,"k":100,"ix":2},"w":{"a":0,"k":3,"ix":2},"lc":2,"lj":2,"ml":4,"d":[{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[12,13.51],"ix":2},"a":{"a":0,"k":[0,5.4],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[80],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":300,"s":[440]}],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":false,"v":[[0,6.83],[9.65,0]],"i":[[0,0],[-3.217,2.278]],"o":[[3.217,-2.278],[0,0]]}}},{"ty":"st","c":{"a":0,"k":[0.153,0.342,0.78],"ix":2},"o":{"a":0,"k":100,"ix":2},"w":{"a":0,"k":3,"ix":2},"lc":2,"lj":2,"ml":4,"d":[{"n":"o","nm":"offset","v":{"a":0,"k":0,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[12,13.51],"ix":2},"a":{"a":0,"k":[0,6.83],"ix":2},"r":{"a":1,"k":[{"t":0,"s":[0],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":300,"s":[720]}],"ix":2},"o":{"a":0,"k":100,"ix":2}}]},{"ty":"tr","p":{"a":0,"k":[9.43,7.85],"ix":2},"a":{"a":0,"k":[16.83,13.13],"ix":2},"s":{"a":0,"k":[29.7,38.61],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":2,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"sh","d":1,"ks":{"a":0,"k":{"c":true,"v":[[7.5,13.5],[1.5,7.5],[7.5,1.5],[13.5,7.5],[7.5,13.5],[7.5,13.5]],"i":[[0,0],[0,3.315],[-3.315,0],[0,-3.315],[3.315,0],[0,0]],"o":[[-3.315,0],[0,-3.315],[3.315,0],[0,3.315],[0,0],[0,0]]}}},{"ty":"st","c":{"a":0,"k":[0.153,0.342,0.78],"ix":2},"o":{"a":0,"k":100,"ix":2},"w":{"a":0,"k":1,"ix":2},"lc":1,"lj":1,"ml":4},{"ty":"fl","c":{"a":0,"k":[0,0.236,0.942],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[8,8],"ix":2},"a":{"a":0,"k":[7.5,7.5],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0}]} \ No newline at end of file diff --git a/img/round-compare-arrows-24-px.png b/img/round-compare-arrows-24-px.png old mode 100755 new mode 100644 index c081c2a4ddf..6d81664c5ae Binary files a/img/round-compare-arrows-24-px.png and b/img/round-compare-arrows-24-px.png differ diff --git a/img/round-compare-arrows-24-px@2x.png b/img/round-compare-arrows-24-px@2x.png old mode 100755 new mode 100644 index c0d35bcd649..3914215853f Binary files a/img/round-compare-arrows-24-px@2x.png and b/img/round-compare-arrows-24-px@2x.png differ diff --git a/img/round-compare-arrows-24-px@3x.png b/img/round-compare-arrows-24-px@3x.png old mode 100755 new mode 100644 index 6296b84e720..d2c04e4641d Binary files a/img/round-compare-arrows-24-px@3x.png and b/img/round-compare-arrows-24-px@3x.png differ diff --git a/img/scan-white.png b/img/scan-white.png index 2602b23c83d..4111cb1203d 100644 Binary files a/img/scan-white.png and b/img/scan-white.png differ diff --git a/img/scan-white@2x.png b/img/scan-white@2x.png index 1547fdd110a..f428036ded1 100644 Binary files a/img/scan-white@2x.png and b/img/scan-white@2x.png differ diff --git a/img/scan-white@3x.png b/img/scan-white@3x.png index f11c274d71f..b89166c2f74 100644 Binary files a/img/scan-white@3x.png and b/img/scan-white@3x.png differ diff --git a/img/scan.png b/img/scan.png old mode 100755 new mode 100644 index 4963942a703..a3fdf1ec546 Binary files a/img/scan.png and b/img/scan.png differ diff --git a/img/scan@2x.png b/img/scan@2x.png old mode 100755 new mode 100644 index fa957c8e7c5..238ee84a436 Binary files a/img/scan@2x.png and b/img/scan@2x.png differ diff --git a/img/scan@3x.png b/img/scan@3x.png old mode 100755 new mode 100644 index 077973a3fdd..58ae85d3d3e Binary files a/img/scan@3x.png and b/img/scan@3x.png differ diff --git a/img/splash/splash.png b/img/splash/splash.png deleted file mode 100644 index cfcf6d35e22..00000000000 Binary files a/img/splash/splash.png and /dev/null differ diff --git a/img/splash/splash@2x.png b/img/splash/splash@2x.png deleted file mode 100644 index cfcf6d35e22..00000000000 Binary files a/img/splash/splash@2x.png and /dev/null differ diff --git a/img/splash/splash@3x.png b/img/splash/splash@3x.png deleted file mode 100644 index 86d9fe298d0..00000000000 Binary files a/img/splash/splash@3x.png and /dev/null differ diff --git a/img/txblock.json b/img/txblock.json new file mode 100644 index 00000000000..8029730e7c4 --- /dev/null +++ b/img/txblock.json @@ -0,0 +1 @@ +{"v":"5.7.5","fr":100,"ip":0,"op":300,"w":100,"h":100,"ddd":0,"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"lottielab.com","sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":2,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":3,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":4,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":5,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":6,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":7,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":8,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":9,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":10,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,96.36],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":11,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":12,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":13,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":14,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":15,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":16,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":17,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":18,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":19,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":20,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":21,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,87.09],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":22,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":23,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":24,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":25,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":26,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":27,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":28,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":29,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":30,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":31,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":32,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,77.82],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":33,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":34,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":35,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":36,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":37,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":38,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":39,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":40,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":41,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":42,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":43,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,68.55],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":44,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":45,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":46,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":47,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":48,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":49,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":50,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":51,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":52,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":53,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":54,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,59.27],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":55,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":56,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":57,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":58,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":59,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":1,"k":[{"t":14,"s":[100],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":84,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":174,"s":[100],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":246,"s":[10],"i":{"x":[0.75],"y":[0.75]},"o":{"x":[0.25],"y":[0.25]}},{"t":274,"s":[100]}],"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":60,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":61,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":62,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":63,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":64,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":65,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,50],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":66,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":67,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":68,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":69,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":70,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":71,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":72,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":73,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":74,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":75,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":76,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,40.73],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":77,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":78,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":79,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":80,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":81,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":82,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":83,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":84,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":85,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":86,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":87,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,31.45],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":88,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":89,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":90,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":91,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":92,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":93,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":94,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":95,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":96,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":97,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":98,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,22.18],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":99,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":100,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":101,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":102,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":103,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":104,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":105,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":106,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":107,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":108,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":109,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,12.91],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":110,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[96.36,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":111,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[87.09,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":112,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[77.82,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":113,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[68.55,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":114,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[59.27,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":115,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[50,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":116,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[40.73,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":117,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[31.45,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":118,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[22.18,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":119,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[12.91,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0},{"ddd":0,"ind":120,"ty":4,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":2}},"ao":0,"shapes":[{"ty":"gr","it":[{"ty":"rc","d":1,"s":{"a":0,"k":[8,8],"ix":2},"p":{"a":0,"k":[0,0],"ix":2},"r":{"a":0,"k":2,"ix":2}},{"ty":"fl","c":{"a":0,"k":[0.75,0.16,0.16],"ix":2},"o":{"a":0,"k":10,"ix":2},"r":1,"bm":0},{"ty":"tr","p":{"a":0,"k":[3.64,3.64],"ix":2},"o":{"a":0,"k":100,"ix":2}}]}],"ip":0,"op":301,"st":0}]} \ No newline at end of file diff --git a/img/vault-shape-rtl.png b/img/vault-shape-rtl.png index 0df10fd982d..c1221844662 100644 Binary files a/img/vault-shape-rtl.png and b/img/vault-shape-rtl.png differ diff --git a/img/vault-shape.png b/img/vault-shape.png index 9e66ced3348..ea2f2562dd0 100644 Binary files a/img/vault-shape.png and b/img/vault-shape.png differ diff --git a/index.js b/index.js index 15b8d9fc576..f222025e369 100644 --- a/index.js +++ b/index.js @@ -1,25 +1,48 @@ -import React, { useEffect } from 'react'; +import './bugsnag'; +import './gesture-handler'; +import 'react-native-get-random-values'; import './shim.js'; -import { AppRegistry } from 'react-native'; + +import React, { useEffect } from 'react'; +import { AppRegistry, LogBox } from 'react-native'; +import BackgroundFetch from 'react-native-background-fetch'; + import App from './App'; -import { BlueStorageProvider } from './blue_modules/storage-context'; +import { restoreSavedPreferredFiatCurrencyAndExchangeFromStorage } from './blue_modules/currency'; +import { runArkBackgroundTask } from './blue_modules/arkade-background'; + +// Android headless execution boots a bare JS runtime without the React tree. +// The headless task callback must be registered at module scope before +// AppRegistry.registerComponent so the symbol exists when the OS dispatches a +// terminated-process wake. +BackgroundFetch.registerHeadlessTask(async event => { + if (event.timeout) { + BackgroundFetch.finish(event.taskId); + return; + } + await runArkBackgroundTask(event.taskId); +}); -const A = require('./blue_modules/analytics'); if (!Error.captureStackTrace) { // captureStackTrace is only available when debugging Error.captureStackTrace = () => {}; } +LogBox.ignoreLogs([ + 'Require cycle:', + 'Battery state `unknown` and monitoring disabled, this is normal for simulators and tvOS.', + 'Open debugger to view warnings.', + 'Non-serializable values were found in the navigation state', +]); + const BlueAppComponent = () => { useEffect(() => { - A(A.ENUM.INIT); + restoreSavedPreferredFiatCurrencyAndExchangeFromStorage().catch(error => { + console.error('Failed to restore preferred currency and exchange rates on startup:', error); + }); }, []); - return ( - - - - ); + return ; }; AppRegistry.registerComponent('BlueWallet', () => BlueAppComponent); diff --git a/ios/BlueWallet-Bridging-Header.h b/ios/BlueWallet-Bridging-Header.h index 1b2cb5d6d09..04328fe71aa 100644 --- a/ios/BlueWallet-Bridging-Header.h +++ b/ios/BlueWallet-Bridging-Header.h @@ -1,4 +1,13 @@ // -// Use this file to import your target's public headers that you would like to expose to Swift. +// BlueWallet-Bridging-Header.h +// BlueWallet +// +// Created by Marcos Rodriguez on 4/4/25. +// Copyright © 2025 BlueWallet. All rights reserved. // +#import "RNNotifications.h" +#import "RNQuickActionManager.h" +#import "NativeEventEmitterSpec.h" +#import "NativeMenuElementsEmitterSpec.h" +#import "NativeWidgetHelperSpec.h" diff --git a/ios/BlueWallet-tvOS/Info.plist b/ios/BlueWallet-tvOS/Info.plist deleted file mode 100644 index 2fb6a11c2c3..00000000000 --- a/ios/BlueWallet-tvOS/Info.plist +++ /dev/null @@ -1,54 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - APPL - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1 - LSRequiresIPhoneOS - - UILaunchStoryboardName - LaunchScreen - UIRequiredDeviceCapabilities - - armv7 - - UISupportedInterfaceOrientations - - UIInterfaceOrientationPortrait - UIInterfaceOrientationLandscapeLeft - UIInterfaceOrientationLandscapeRight - - UIViewControllerBasedStatusBarAppearance - - NSLocationWhenInUseUsageDescription - - NSAppTransportSecurity - - - NSExceptionDomains - - localhost - - NSExceptionAllowsInsecureHTTPLoads - - - - - - diff --git a/ios/BlueWallet-tvOSTests/Info.plist b/ios/BlueWallet-tvOSTests/Info.plist deleted file mode 100644 index 886825ccc9b..00000000000 --- a/ios/BlueWallet-tvOSTests/Info.plist +++ /dev/null @@ -1,24 +0,0 @@ - - - - - CFBundleDevelopmentRegion - en - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - org.reactjs.native.example.$(PRODUCT_NAME:rfc1034identifier) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - BNDL - CFBundleShortVersionString - 1.0 - CFBundleSignature - ???? - CFBundleVersion - 1 - - diff --git a/ios/BlueWallet.xcodeproj/project.pbxproj b/ios/BlueWallet.xcodeproj/project.pbxproj index 977ca66b95f..93e95b56c76 100644 --- a/ios/BlueWallet.xcodeproj/project.pbxproj +++ b/ios/BlueWallet.xcodeproj/project.pbxproj @@ -3,35 +3,25 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 63; objects = { /* Begin PBXBuildFile section */ 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; - 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; - 32B5A32A2334450100F8D608 /* Bridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B5A3292334450100F8D608 /* Bridge.swift */; }; - 32F0A29A2311DBB20095C559 /* ComplicationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F0A2992311DBB20095C559 /* ComplicationController.swift */; }; + 17CDA0718F42DB2CE856C872 /* libPods-BlueWallet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 040819EDF8BD9C50A9C83E24 /* libPods-BlueWallet.a */; }; 6D2A6464258BA92D0092292B /* Stickers.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6D2A6463258BA92D0092292B /* Stickers.xcassets */; }; - 6D2A6468258BA92D0092292B /* Stickers.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6D2A6461258BA92C0092292B /* Stickers.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 6D32C5C62596CE3A008C077C /* EventEmitter.m in Sources */ = {isa = PBXBuildFile; fileRef = 6D32C5C52596CE3A008C077C /* EventEmitter.m */; }; - 6D4AF15925D21172009DD853 /* WidgetAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6A254BAB1B007B5B82 /* WidgetAPI.swift */; }; - 6D4AF16325D21185009DD853 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6B254BAB1B007B5B82 /* WidgetDataStore.swift */; }; - 6D4AF16D25D21192009DD853 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4BFA254FBA0E00E9F9AA /* Models.swift */; }; - 6D4AF17825D211A3009DD853 /* FiatUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2AA8072568B8F40090B089 /* FiatUnit.swift */; }; - 6D4AF18425D215D1009DD853 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */; }; + 6D2A6468258BA92D0092292B /* Stickers.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6D2A6461258BA92C0092292B /* Stickers.appex */; platformFilter = ios; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6DD4109D266CADF10087DE03 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D333B3A252FE1A3004D72DF /* WidgetKit.framework */; }; 6DD4109E266CADF10087DE03 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6D333B3C252FE1A3004D72DF /* SwiftUI.framework */; }; 6DD410A1266CADF10087DE03 /* Widgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DD410A0266CADF10087DE03 /* Widgets.swift */; }; - 6DD410A7266CADF40087DE03 /* WidgetsExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 6DD4109C266CADF10087DE03 /* WidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 6DD410A7266CADF40087DE03 /* WidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 6DD4109C266CADF10087DE03 /* WidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 6DD410AC266CAE470087DE03 /* PriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */; }; - 6DD410AE266CAF1F0087DE03 /* fiatUnits.json in Resources */ = {isa = PBXBuildFile; fileRef = 6DD410AD266CAF1F0087DE03 /* fiatUnits.json */; }; 6DD410AF266CAF5C0087DE03 /* WalletInformationAndMarketWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E06254BA347007B5B82 /* WalletInformationAndMarketWidget.swift */; }; 6DD410B0266CAF5C0087DE03 /* WalletInformationWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4AB1254FB59C00E9F9AA /* WalletInformationWidget.swift */; }; - 6DD410B1266CAF5C0087DE03 /* WidgetAPI+Electrum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5142558EBA3009312A5 /* WidgetAPI+Electrum.swift */; }; + 6DD410B1266CAF5C0087DE03 /* MarketAPI+Electrum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5142558EBA3009312A5 /* MarketAPI+Electrum.swift */; }; 6DD410B2266CAF5C0087DE03 /* WalletInformationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D641F2225525053003792DF /* WalletInformationView.swift */; }; 6DD410B3266CAF5C0087DE03 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4C3A254FBF4800E9F9AA /* Colors.swift */; }; - 6DD410B4266CAF5C0087DE03 /* WidgetAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6A254BAB1B007B5B82 /* WidgetAPI.swift */; }; - 6DD410B5266CAF5C0087DE03 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6B254BAB1B007B5B82 /* WidgetDataStore.swift */; }; + 6DD410B4266CAF5C0087DE03 /* MarketAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6A254BAB1B007B5B82 /* MarketAPI.swift */; }; 6DD410B6266CAF5C0087DE03 /* PriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5272558EC52009312A5 /* PriceView.swift */; }; 6DD410B7266CAF5C0087DE03 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6D9A2E08254BA348007B5B82 /* Assets.xcassets */; }; 6DD410B8266CAF5C0087DE03 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */; }; @@ -39,39 +29,84 @@ 6DD410BA266CAF5C0087DE03 /* FiatUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2AA8072568B8F40090B089 /* FiatUnit.swift */; }; 6DD410BB266CAF5C0087DE03 /* MarketView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D641F17255226DA003792DF /* MarketView.swift */; }; 6DD410BE266CAF5C0087DE03 /* SendReceiveButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D641F3425526311003792DF /* SendReceiveButtons.swift */; }; - 6DD410BF266CB13D0087DE03 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4BFA254FBA0E00E9F9AA /* Models.swift */; }; + 6DD410BF266CB13D0087DE03 /* Placeholders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4BFA254FBA0E00E9F9AA /* Placeholders.swift */; }; 6DD410C0266CB1460087DE03 /* MarketWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9946622555A660000E52E8 /* MarketWidget.swift */; }; 6DF25A9F249DB97E001D06F5 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 6DF25A9E249DB97E001D06F5 /* LaunchScreen.storyboard */; }; - 6DFC807024EA0B6C007B8700 /* EFQRCode in Frameworks */ = {isa = PBXBuildFile; productRef = 6DFC806F24EA0B6C007B8700 /* EFQRCode */; }; - 6DFC807224EA2FA9007B8700 /* ViewQRCodefaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DFC807124EA2FA9007B8700 /* ViewQRCodefaceController.swift */; }; 764B49B1420D4AEB8109BF62 /* libsqlite3.0.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B468CC34D5B41F3950078EF /* libsqlite3.0.tbd */; }; 782F075B5DD048449E2DECE9 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = B9D9B3A7B2CB4255876B67AF /* libz.tbd */; }; - 849047CA2702A32A008EE567 /* Handoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849047C92702A32A008EE567 /* Handoff.swift */; }; 84E05A842721191B001A0D3A /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 84E05A832721191B001A0D3A /* Settings.bundle */; }; - 906451CAD44154C2950030EC /* libPods-BlueWallet.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 731973BA0AC6EA78962CE5B6 /* libPods-BlueWallet.a */; }; - B40D4E34225841EC00428FCC /* Interface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E32225841EC00428FCC /* Interface.storyboard */; }; - B40D4E36225841ED00428FCC /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; }; - B40D4E3D225841ED00428FCC /* BlueWalletWatch Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B40D4E3C225841ED00428FCC /* BlueWalletWatch Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - B40D4E44225841ED00428FCC /* ExtensionDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E43225841ED00428FCC /* ExtensionDelegate.swift */; }; - B40D4E46225841ED00428FCC /* NotificationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E45225841ED00428FCC /* NotificationController.swift */; }; - B40D4E4D225841ED00428FCC /* BlueWalletWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = B40D4E30225841EC00428FCC /* BlueWalletWatch.app */; platformFilter = ios; }; - B40D4E5D2258425500428FCC /* InterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E552258425400428FCC /* InterfaceController.swift */; }; - B40D4E5E2258425500428FCC /* NumericKeypadInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E562258425400428FCC /* NumericKeypadInterfaceController.swift */; }; - B40D4E602258425500428FCC /* SpecifyInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E582258425400428FCC /* SpecifyInterfaceController.swift */; }; - B40D4E632258425500428FCC /* ReceiveInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E5B2258425500428FCC /* ReceiveInterfaceController.swift */; }; - B40D4E642258425500428FCC /* WalletDetailsInterfaceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E5C2258425500428FCC /* WalletDetailsInterfaceController.swift */; }; - B40D4E682258426B00428FCC /* KeychainSwiftDistrib.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40D4E672258426B00428FCC /* KeychainSwiftDistrib.swift */; }; + B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */; }; B40FC3FA29CCD1D00007EBAC /* SwiftTCPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40FC3F829CCD1AC0007EBAC /* SwiftTCPClient.swift */; }; - B43D0378225847C500FBAA95 /* WalletGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0372225847C500FBAA95 /* WalletGradient.swift */; }; - B43D0379225847C500FBAA95 /* WatchDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0373225847C500FBAA95 /* WatchDataSource.swift */; }; - B43D037A225847C500FBAA95 /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0374225847C500FBAA95 /* Transaction.swift */; }; - B43D037B225847C500FBAA95 /* TransactionTableRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0375225847C500FBAA95 /* TransactionTableRow.swift */; }; - B43D037C225847C500FBAA95 /* Wallet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0376225847C500FBAA95 /* Wallet.swift */; }; - B43D037D225847C500FBAA95 /* WalletInformation.swift in Sources */ = {isa = PBXBuildFile; fileRef = B43D0377225847C500FBAA95 /* WalletInformation.swift */; }; - B461B852299599F800E431AA /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = B461B851299599F800E431AA /* AppDelegate.mm */; }; - B4EE583C226703320003363C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B40D4E35225841ED00428FCC /* Assets.xcassets */; }; - E5D4794B26781FC0007838C1 /* fiatUnits.json in Resources */ = {isa = PBXBuildFile; fileRef = 6DD410AD266CAF1F0087DE03 /* fiatUnits.json */; }; - E5D4794C26781FC1007838C1 /* fiatUnits.json in Resources */ = {isa = PBXBuildFile; fileRef = 6DD410AD266CAF1F0087DE03 /* fiatUnits.json */; }; + B41C2E562BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */; }; + B41C2E582BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */; }; + B44033BF2BCC32F800162242 /* BitcoinUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033BE2BCC32F800162242 /* BitcoinUnit.swift */; }; + B44033C12BCC32F800162242 /* BitcoinUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033BE2BCC32F800162242 /* BitcoinUnit.swift */; }; + B44033C42BCC332400162242 /* Balance.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033C32BCC332400162242 /* Balance.swift */; }; + B44033C62BCC332400162242 /* Balance.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033C32BCC332400162242 /* Balance.swift */; }; + B44033CA2BCC350A00162242 /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033C92BCC350A00162242 /* Currency.swift */; }; + B44033CC2BCC350A00162242 /* Currency.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033C92BCC350A00162242 /* Currency.swift */; }; + B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DA7047D254E24D5005FE5E2 /* UserDefaultsGroup.swift */; }; + B44033D32BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033D22BCC368800162242 /* UserDefaultsGroupKey.swift */; }; + B44033D52BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033D22BCC368800162242 /* UserDefaultsGroupKey.swift */; }; + B44033D82BCC369500162242 /* UserDefaultsExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */; }; + B44033DA2BCC369A00162242 /* Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4C3A254FBF4800E9F9AA /* Colors.swift */; }; + B44033DD2BCC36C300162242 /* LatestTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033DC2BCC36C300162242 /* LatestTransaction.swift */; }; + B44033DF2BCC36C300162242 /* LatestTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033DC2BCC36C300162242 /* LatestTransaction.swift */; }; + B44033E22BCC36CB00162242 /* Placeholders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DEB4BFA254FBA0E00E9F9AA /* Placeholders.swift */; }; + B44033E42BCC36FF00162242 /* WalletData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E32BCC36FF00162242 /* WalletData.swift */; }; + B44033E62BCC36FF00162242 /* WalletData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E32BCC36FF00162242 /* WalletData.swift */; }; + B44033EB2BCC371A00162242 /* MarketData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E82BCC371A00162242 /* MarketData.swift */; }; + B44033EE2BCC374500162242 /* Numeric+abbreviated.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033ED2BCC374500162242 /* Numeric+abbreviated.swift */; }; + B44033F02BCC374500162242 /* Numeric+abbreviated.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033ED2BCC374500162242 /* Numeric+abbreviated.swift */; }; + B44033F42BCC377F00162242 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033F32BCC377F00162242 /* WidgetData.swift */; }; + B44033F62BCC377F00162242 /* WidgetData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033F32BCC377F00162242 /* WidgetData.swift */; }; + B44033F92BCC379200162242 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033F82BCC379200162242 /* WidgetDataStore.swift */; }; + B44033FB2BCC379200162242 /* WidgetDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033F82BCC379200162242 /* WidgetDataStore.swift */; }; + B44033FE2BCC37D700162242 /* MarketAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D9A2E6A254BAB1B007B5B82 /* MarketAPI.swift */; }; + B44034002BCC37F800162242 /* Bundle+decode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033FF2BCC37F800162242 /* Bundle+decode.swift */; }; + B44034022BCC37F800162242 /* Bundle+decode.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033FF2BCC37F800162242 /* Bundle+decode.swift */; }; + B44034052BCC389200162242 /* XMLParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */; }; + B44034072BCC38A000162242 /* FiatUnit.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D2AA8072568B8F40090B089 /* FiatUnit.swift */; }; + B440340F2BCC40A400162242 /* fiatUnits.json in Resources */ = {isa = PBXBuildFile; fileRef = B440340E2BCC40A400162242 /* fiatUnits.json */; }; + B44034112BCC40A400162242 /* fiatUnits.json in Resources */ = {isa = PBXBuildFile; fileRef = B440340E2BCC40A400162242 /* fiatUnits.json */; }; + B450109C2C0FCD8A00619044 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B450109B2C0FCD8A00619044 /* Utilities.swift */; }; + B450109D2C0FCD9F00619044 /* Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = B450109B2C0FCD8A00619044 /* Utilities.swift */; }; + B461B852299599F800E431AA /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B461B851299599F800E431AA /* AppDelegate.swift */; }; + B4742E972CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; }; + B4742E9A2CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; }; + B4742E9B2CCDBE8300380EEE /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = B4742E962CCDBE8300380EEE /* Localizable.xcstrings */; }; + B4793DBB2CEDACBD00C92C2E /* Chain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4793DBA2CEDACBD00C92C2E /* Chain.swift */; }; + B4793DBC2CEDACBD00C92C2E /* Chain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4793DBA2CEDACBD00C92C2E /* Chain.swift */; }; + B4793DC32CEDAD4400C92C2E /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4793DC22CEDAD4400C92C2E /* KeychainHelper.swift */; }; + B4793DC52CEDAD4400C92C2E /* KeychainHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4793DC22CEDAD4400C92C2E /* KeychainHelper.swift */; }; + B48630D62CCEE67100A8425C /* PriceWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */; }; + B48630DD2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */; }; + B48630DE2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */; }; + B48630E02CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */; }; + B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */; }; + B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA5272558EC52009312A5 /* PriceView.swift */; }; + B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */; }; + B48630E82CCEE92400A8425C /* PriceWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */; }; + B48630EA2CCEED8400A8425C /* PriceIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D02CCEE3B300A8425C /* PriceIntent.swift */; }; + B48630EC2CCEEEA700A8425C /* WalletAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */; }; + B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */; }; + B48630EE2CCEEEE900A8425C /* PriceIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = B48630D02CCEE3B300A8425C /* PriceIntent.swift */; }; + B49A28BB2CD18999006B08E4 /* CompactPriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */; }; + B49A28BC2CD18999006B08E4 /* CompactPriceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */; }; + B49A28BE2CD189B0006B08E4 /* FiatUnitEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */; }; + B49A28BF2CD18A9A006B08E4 /* FiatUnitEnum.swift in Sources */ = {isa = PBXBuildFile; fileRef = B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */; }; + B49A28C52CD1A894006B08E4 /* MarketData.swift in Sources */ = {isa = PBXBuildFile; fileRef = B44033E82BCC371A00162242 /* MarketData.swift */; }; + B4AA75242DAA339E00CF5CBE /* MenuElementsEmitter.m in Sources */ = {isa = PBXBuildFile; fileRef = B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */; }; + B4AB225E2B02AD12001F4328 /* XMLParserDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */; }; + B4B1A4622BFA73110072E3BB /* WidgetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */; }; + B4B1A4642BFA73110072E3BB /* WidgetHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */; }; + B4B3EC222D69FF6C00327F3D /* SegmentedControlView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B3EC202D69FF6C00327F3D /* SegmentedControlView.swift */; }; + B4B3EC252D69FF8700327F3D /* EventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B3EC232D69FF8700327F3D /* EventEmitter.swift */; }; + B4B3EF102D6AFF6C003270A0 /* SegmentedControlManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B3EF002D6AFF6C003270A0 /* SegmentedControlManager.swift */; }; + B4B3EF202D6CFF6C003270A0 /* SegmentedControlBridge.m in Sources */ = {isa = PBXBuildFile; fileRef = B4B3EF1F2D6CFF6C003270A0 /* SegmentedControlBridge.m */; }; + B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */; }; + B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */ = {isa = PBXBuildFile; fileRef = B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */; }; + C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */ = {isa = PBXBuildFile; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -96,213 +131,136 @@ remoteGlobalIDString = 6DD4109B266CADF10087DE03; remoteInfo = WidgetsExtension; }; - B40D4E3E225841ED00428FCC /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; - proxyType = 1; - remoteGlobalIDString = B40D4E3B225841ED00428FCC; - remoteInfo = "BlueWalletWatch Extension"; - }; - B40D4E4B225841ED00428FCC /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; - proxyType = 1; - remoteGlobalIDString = B40D4E2F225841EC00428FCC; - remoteInfo = BlueWalletWatch; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 3271B0B6236E2E0700DA766F /* Embed App Extensions */ = { + 3271B0B6236E2E0700DA766F /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 13; files = ( - 6D2A6468258BA92D0092292B /* Stickers.appex in Embed App Extensions */, - 6DD410A7266CADF40087DE03 /* WidgetsExtension.appex in Embed App Extensions */, + 6D2A6468258BA92D0092292B /* Stickers.appex in Embed Foundation Extensions */, + 6DD410A7266CADF40087DE03 /* WidgetsExtension.appex in Embed Foundation Extensions */, ); - name = "Embed App Extensions"; - runOnlyForDeploymentPostprocessing = 0; - }; - B40D4E2D225841C300428FCC /* Embed Watch Content */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; - dstSubfolderSpec = 16; - files = ( - B40D4E4D225841ED00428FCC /* BlueWalletWatch.app in Embed Watch Content */, - ); - name = "Embed Watch Content"; - runOnlyForDeploymentPostprocessing = 0; - }; - B40D4E51225841ED00428FCC /* Embed App Extensions */ = { - isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; - dstPath = ""; - dstSubfolderSpec = 13; - files = ( - B40D4E3D225841ED00428FCC /* BlueWalletWatch Extension.appex in Embed App Extensions */, - ); - name = "Embed App Extensions"; + name = "Embed Foundation Extensions"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; 00E356F11AD99517003FC87E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 00E356F21AD99517003FC87E /* BlueWalletTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BlueWalletTests.m; sourceTree = ""; }; - 04466491BA2D4876A71222FC /* Foundation.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Foundation.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Foundation.ttf"; sourceTree = ""; }; + 040819EDF8BD9C50A9C83E24 /* libPods-BlueWallet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BlueWallet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* BlueWallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlueWallet.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = BlueWallet/Images.xcassets; sourceTree = ""; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = BlueWallet/Info.plist; sourceTree = ""; }; - 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = BlueWallet/main.m; sourceTree = ""; }; + 1AE7FA8B4A18928E917F42D1 /* Pods-BlueWallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlueWallet.debug.xcconfig"; path = "Target Support Files/Pods-BlueWallet/Pods-BlueWallet.debug.xcconfig"; sourceTree = ""; }; 1DD63E4B5C8344BB9880C9EC /* libReactNativePermissions.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libReactNativePermissions.a; sourceTree = ""; }; 253243E162CE4822BF3A3B7D /* libRNRandomBytes-tvOS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = "libRNRandomBytes-tvOS.a"; sourceTree = ""; }; 2654894D4DE44A4C8F71773D /* CoreData.framework */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = wrapper.framework; name = CoreData.framework; path = System/Library/Frameworks/CoreData.framework; sourceTree = SDKROOT; }; 2D16E6891FA4F8E400B85C8A /* libReact.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libReact.a; sourceTree = BUILT_PRODUCTS_DIR; }; - 2FCC2CD6FF4448229D0CE0F3 /* MaterialCommunityIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = MaterialCommunityIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf"; sourceTree = ""; }; 3271B0AA236E2E0700DA766F /* NotificationCenter.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NotificationCenter.framework; path = System/Library/Frameworks/NotificationCenter.framework; sourceTree = SDKROOT; }; - 32B5A3282334450100F8D608 /* BlueWallet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BlueWallet-Bridging-Header.h"; sourceTree = ""; }; - 32B5A3292334450100F8D608 /* Bridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bridge.swift; sourceTree = ""; }; 32C7944323B8879D00BE2AFA /* BlueWalletRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWalletRelease.entitlements; path = BlueWallet/BlueWalletRelease.entitlements; sourceTree = ""; }; - 32F0A24F2310B0700095C559 /* BlueWalletWatch Extension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "BlueWalletWatch Extension.entitlements"; sourceTree = ""; }; 32F0A2502310B0910095C559 /* BlueWallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = BlueWallet.entitlements; path = BlueWallet/BlueWallet.entitlements; sourceTree = ""; }; - 32F0A2992311DBB20095C559 /* ComplicationController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ComplicationController.swift; sourceTree = ""; }; - 334051161886419EA186F4BA /* FontAwesome.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf"; sourceTree = ""; }; - 367FA8CEB35BC9431019D98A /* Pods-MarketWidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MarketWidgetExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-MarketWidgetExtension/Pods-MarketWidgetExtension.debug.xcconfig"; sourceTree = ""; }; 3703B10AAB374CF896CCC2EA /* libBVLinearGradient.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libBVLinearGradient.a; sourceTree = ""; }; - 3F7F1B8332C6439793D55B45 /* EvilIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = EvilIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf"; sourceTree = ""; }; - 41BD3AC9FD81723B68A63C12 /* libPods-MarketWidgetExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-MarketWidgetExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 44BC9E3EE0E9476A830CCCB9 /* Entypo.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Entypo.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Entypo.ttf"; sourceTree = ""; }; - 47564776A7A3427DB36C087D /* FontAwesome5_Regular.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome5_Regular.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf"; sourceTree = ""; }; - 47C436B1EF23484B8181DBEA /* Zocial.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Zocial.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Zocial.ttf"; sourceTree = ""; }; - 4D746BBE67E84684848246E2 /* SimpleLineIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = SimpleLineIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf"; sourceTree = ""; }; 4F12F501B686459183E0BE0D /* libRNVectorIcons.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNVectorIcons.a; sourceTree = ""; }; - 5A8F67CF29564E41882ECEF8 /* FontAwesome5_Brands.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome5_Brands.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf"; sourceTree = ""; }; 6A65D81712444D37BA152B06 /* libRNRandomBytes.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNRandomBytes.a; sourceTree = ""; }; - 6D203C2025D4ED2500493AD1 /* BlueWalletWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = BlueWalletWatch.entitlements; sourceTree = ""; }; - 6D294A7324D510AC0039E22B /* af */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = af; path = af.lproj/Interface.strings; sourceTree = ""; }; - 6D294A7524D510D60039E22B /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Interface.strings; sourceTree = ""; }; - 6D294A7724D510E60039E22B /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Interface.strings"; sourceTree = ""; }; - 6D294A7924D510EA0039E22B /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Interface.strings"; sourceTree = ""; }; - 6D294A7B24D510F40039E22B /* hr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hr; path = hr.lproj/Interface.strings; sourceTree = ""; }; - 6D294A7D24D5111E0039E22B /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Interface.strings; sourceTree = ""; }; - 6D294A7F24D511640039E22B /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8124D511690039E22B /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8324D511720039E22B /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8524D511750039E22B /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8724D5117A0039E22B /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8924D511800039E22B /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8B24D511CB0039E22B /* hu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = hu; path = hu.lproj/Interface.strings; sourceTree = ""; }; - 6D294A8D24D5121D0039E22B /* pt-BR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-BR"; path = "pt-BR.lproj/Interface.strings"; sourceTree = ""; }; - 6D294A8F24D512230039E22B /* pt-PT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pt-PT"; path = "pt-PT.lproj/Interface.strings"; sourceTree = ""; }; - 6D294A9124D512260039E22B /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9324D512320039E22B /* sk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sk; path = sk.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9524D5125F0039E22B /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9724D512620039E22B /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9924D512690039E22B /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9B24D512770039E22B /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Interface.strings; sourceTree = ""; }; - 6D294A9D24D5127F0039E22B /* xh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = xh; path = xh.lproj/Interface.strings; sourceTree = ""; }; 6D2A6461258BA92C0092292B /* Stickers.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Stickers.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6D2A6463258BA92D0092292B /* Stickers.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Stickers.xcassets; sourceTree = ""; }; 6D2A6465258BA92D0092292B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6D2AA8072568B8F40090B089 /* FiatUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiatUnit.swift; sourceTree = ""; }; - 6D32C5C42596CE2F008C077C /* EventEmitter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = EventEmitter.h; sourceTree = ""; }; - 6D32C5C52596CE3A008C077C /* EventEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = EventEmitter.m; sourceTree = ""; }; 6D333B3A252FE1A3004D72DF /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 6D333B3C252FE1A3004D72DF /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; - 6D4AF18225D215D0009DD853 /* BlueWalletWatch-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BlueWalletWatch-Bridging-Header.h"; sourceTree = ""; }; 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsExtension.swift; sourceTree = ""; }; 6D641F17255226DA003792DF /* MarketView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketView.swift; sourceTree = ""; }; 6D641F2225525053003792DF /* WalletInformationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletInformationView.swift; sourceTree = ""; }; 6D641F3425526311003792DF /* SendReceiveButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendReceiveButtons.swift; sourceTree = ""; }; 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidget.swift; sourceTree = ""; }; - 6D6CA5142558EBA3009312A5 /* WidgetAPI+Electrum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WidgetAPI+Electrum.swift"; sourceTree = ""; }; + 6D6CA5142558EBA3009312A5 /* MarketAPI+Electrum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MarketAPI+Electrum.swift"; sourceTree = ""; }; 6D6CA5272558EC52009312A5 /* PriceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceView.swift; sourceTree = ""; }; 6D9946622555A660000E52E8 /* MarketWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketWidget.swift; sourceTree = ""; }; 6D9A2E06254BA347007B5B82 /* WalletInformationAndMarketWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletInformationAndMarketWidget.swift; sourceTree = ""; }; 6D9A2E08254BA348007B5B82 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 6D9A2E6A254BAB1B007B5B82 /* WidgetAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WidgetAPI.swift; sourceTree = ""; }; - 6D9A2E6B254BAB1B007B5B82 /* WidgetDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; + 6D9A2E6A254BAB1B007B5B82 /* MarketAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MarketAPI.swift; sourceTree = ""; }; 6DA7047D254E24D5005FE5E2 /* UserDefaultsGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsGroup.swift; sourceTree = ""; }; 6DD4109C266CADF10087DE03 /* WidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 6DD410A0266CADF10087DE03 /* Widgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widgets.swift; sourceTree = ""; }; 6DD410A4266CADF40087DE03 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 6DD410AD266CAF1F0087DE03 /* fiatUnits.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = fiatUnits.json; path = ../../../../models/fiatUnits.json; sourceTree = ""; }; 6DD410C3266CCB780087DE03 /* WidgetsExtension.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = WidgetsExtension.entitlements; sourceTree = SOURCE_ROOT; }; 6DEB4AB1254FB59C00E9F9AA /* WalletInformationWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletInformationWidget.swift; sourceTree = ""; }; - 6DEB4BFA254FBA0E00E9F9AA /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = ""; }; + 6DEB4BFA254FBA0E00E9F9AA /* Placeholders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholders.swift; sourceTree = ""; }; 6DEB4C3A254FBF4800E9F9AA /* Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Colors.swift; sourceTree = ""; }; 6DF25A9E249DB97E001D06F5 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; - 6DFC807124EA2FA9007B8700 /* ViewQRCodefaceController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewQRCodefaceController.swift; sourceTree = ""; }; 6EB3338E347F4AFAA8C85C04 /* libRNDeviceInfo-tvOS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = "libRNDeviceInfo-tvOS.a"; sourceTree = ""; }; 70C9C17A3F52430B99582AF4 /* libRNCamera.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCamera.a; sourceTree = ""; }; - 731973BA0AC6EA78962CE5B6 /* libPods-BlueWallet.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-BlueWallet.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 78A87E7251D94144A71A2F67 /* FontAwesome5_Solid.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = FontAwesome5_Solid.ttf; path = "../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf"; sourceTree = ""; }; 7B468CC34D5B41F3950078EF /* libsqlite3.0.tbd */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libsqlite3.0.tbd; path = usr/lib/libsqlite3.0.tbd; sourceTree = SDKROOT; }; - 7BAA8F97E61B677D33CF1944 /* Pods-WalletInformationAndMarketWidgetExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WalletInformationAndMarketWidgetExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-WalletInformationAndMarketWidgetExtension/Pods-WalletInformationAndMarketWidgetExtension.release.xcconfig"; sourceTree = ""; }; 8448882949434D41A054C0B2 /* ToolTipMenuTests.xctest */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = ToolTipMenuTests.xctest; sourceTree = ""; }; - 849047C92702A32A008EE567 /* Handoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Handoff.swift; sourceTree = ""; }; 84E05A832721191B001A0D3A /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = ""; }; 8637D4B5E14D443A9031DA95 /* libRNFS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNFS.a; sourceTree = ""; }; 90F86BC5194548CA87D729A9 /* libToolTipMenu.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libToolTipMenu.a; sourceTree = ""; }; + 93D74F9C8EE7B4443A49594C /* Pods-BlueWallet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlueWallet.release.xcconfig"; path = "Target Support Files/Pods-BlueWallet/Pods-BlueWallet.release.xcconfig"; sourceTree = ""; }; 94565BFC6A0C4235B3EC7B01 /* libRNSVG.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNSVG.a; sourceTree = ""; }; 95208B2A05884A76B5BB99C0 /* libRCTGoogleAnalyticsBridge.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTGoogleAnalyticsBridge.a; sourceTree = ""; }; - 98455D960744E4E5DD50BA87 /* libPods-WalletInformationAndMarketWidgetExtension.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-WalletInformationAndMarketWidgetExtension.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - 9B3A324B70BC8C6D9314FD4F /* Pods-BlueWallet.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlueWallet.debug.xcconfig"; path = "Pods/Target Support Files/Pods-BlueWallet/Pods-BlueWallet.debug.xcconfig"; sourceTree = ""; }; 9DF4E6C040764E4BA1ACC1EB /* libTcpSockets.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libTcpSockets.a; sourceTree = ""; }; 9F1F51A83D044F3BB26A35FC /* libRNSVG-tvOS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = "libRNSVG-tvOS.a"; sourceTree = ""; }; A7C4B1FDAD264618BAF8C335 /* libRNCWebView.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCWebView.a; sourceTree = ""; }; - A9166D490AEF4938BD6621CF /* Feather.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Feather.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Feather.ttf"; sourceTree = ""; }; - AA7DCFB2C7887DF26EDB5710 /* Pods-WalletInformationAndMarketWidgetExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-WalletInformationAndMarketWidgetExtension.debug.xcconfig"; path = "Pods/Target Support Files/Pods-WalletInformationAndMarketWidgetExtension/Pods-WalletInformationAndMarketWidgetExtension.debug.xcconfig"; sourceTree = ""; }; AB2325650CE04F018697ACFE /* libRNReactNativeHapticFeedback.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNReactNativeHapticFeedback.a; sourceTree = ""; }; - B40D4E30225841EC00428FCC /* BlueWalletWatch.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BlueWalletWatch.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B40D4E33225841EC00428FCC /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Interface.storyboard; sourceTree = ""; }; - B40D4E35225841ED00428FCC /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - B40D4E37225841ED00428FCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B40D4E3C225841ED00428FCC /* BlueWalletWatch Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "BlueWalletWatch Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; - B40D4E43225841ED00428FCC /* ExtensionDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExtensionDelegate.swift; sourceTree = ""; }; - B40D4E45225841ED00428FCC /* NotificationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationController.swift; sourceTree = ""; }; - B40D4E49225841ED00428FCC /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - B40D4E4A225841ED00428FCC /* PushNotificationPayload.apns */ = {isa = PBXFileReference; lastKnownFileType = text; path = PushNotificationPayload.apns; sourceTree = ""; }; - B40D4E552258425400428FCC /* InterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InterfaceController.swift; sourceTree = ""; }; - B40D4E562258425400428FCC /* NumericKeypadInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NumericKeypadInterfaceController.swift; sourceTree = ""; }; - B40D4E582258425400428FCC /* SpecifyInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SpecifyInterfaceController.swift; sourceTree = ""; }; - B40D4E5B2258425500428FCC /* ReceiveInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReceiveInterfaceController.swift; sourceTree = ""; }; - B40D4E5C2258425500428FCC /* WalletDetailsInterfaceController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletDetailsInterfaceController.swift; sourceTree = ""; }; - B40D4E672258426B00428FCC /* KeychainSwiftDistrib.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeychainSwiftDistrib.swift; sourceTree = SOURCE_ROOT; }; + B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = MenuElementsEmitter.swift; path = MenuElementsEmitter/MenuElementsEmitter.swift; sourceTree = SOURCE_ROOT; }; B40FC3F829CCD1AC0007EBAC /* SwiftTCPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTCPClient.swift; sourceTree = ""; }; - B43B69B8225C462E00925B1E /* libPods-RCTLinking.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = "libPods-RCTLinking.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; B43B69BA225C46D800925B1E /* libRCTLinking.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libRCTLinking.a; sourceTree = BUILT_PRODUCTS_DIR; }; - B43D0372225847C500FBAA95 /* WalletGradient.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletGradient.swift; sourceTree = ""; }; - B43D0373225847C500FBAA95 /* WatchDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WatchDataSource.swift; sourceTree = ""; }; - B43D0374225847C500FBAA95 /* Transaction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = ""; }; - B43D0375225847C500FBAA95 /* TransactionTableRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionTableRow.swift; sourceTree = ""; }; - B43D0376225847C500FBAA95 /* Wallet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Wallet.swift; sourceTree = ""; }; - B43D0377225847C500FBAA95 /* WalletInformation.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletInformation.swift; sourceTree = ""; }; - B43D046E22584C1B00FBAA95 /* libRNWatch.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; path = libRNWatch.a; sourceTree = BUILT_PRODUCTS_DIR; }; - B459EE96941AE09BCB547DC0 /* Pods-BlueWallet.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-BlueWallet.release.xcconfig"; path = "Pods/Target Support Files/Pods-BlueWallet/Pods-BlueWallet.release.xcconfig"; sourceTree = ""; }; - B461B850299599F800E431AA /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = BlueWallet/AppDelegate.h; sourceTree = ""; }; - B461B851299599F800E431AA /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = BlueWallet/AppDelegate.mm; sourceTree = ""; }; + B44033BE2BCC32F800162242 /* BitcoinUnit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitcoinUnit.swift; sourceTree = ""; }; + B44033C32BCC332400162242 /* Balance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Balance.swift; sourceTree = ""; }; + B44033C92BCC350A00162242 /* Currency.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Currency.swift; sourceTree = ""; }; + B44033D22BCC368800162242 /* UserDefaultsGroupKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaultsGroupKey.swift; sourceTree = ""; }; + B44033DC2BCC36C300162242 /* LatestTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestTransaction.swift; sourceTree = ""; }; + B44033E32BCC36FF00162242 /* WalletData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletData.swift; sourceTree = ""; }; + B44033E82BCC371A00162242 /* MarketData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarketData.swift; sourceTree = ""; }; + B44033ED2BCC374500162242 /* Numeric+abbreviated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Numeric+abbreviated.swift"; sourceTree = ""; }; + B44033F32BCC377F00162242 /* WidgetData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetData.swift; sourceTree = ""; }; + B44033F82BCC379200162242 /* WidgetDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetDataStore.swift; sourceTree = ""; }; + B44033FF2BCC37F800162242 /* Bundle+decode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+decode.swift"; sourceTree = ""; }; + B440340E2BCC40A400162242 /* fiatUnits.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = fiatUnits.json; path = ../../../models/fiatUnits.json; sourceTree = ""; }; + B450109B2C0FCD8A00619044 /* Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Utilities.swift; sourceTree = ""; }; + B461B851299599F800E431AA /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = BlueWallet/AppDelegate.swift; sourceTree = ""; }; + B4742E962CCDBE8300380EEE /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + B4793DBA2CEDACBD00C92C2E /* Chain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Chain.swift; sourceTree = ""; }; + B4793DC22CEDAD4400C92C2E /* KeychainHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainHelper.swift; sourceTree = ""; }; + B47B21EB2B2128B8001F6690 /* BlueWalletUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITests.swift; sourceTree = ""; }; + B48283572DA0DE02007EEC62 /* BlueWallet-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "BlueWallet-Bridging-Header.h"; sourceTree = ""; }; + B48630D02CCEE3B300A8425C /* PriceIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceIntent.swift; sourceTree = ""; }; + B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetProvider.swift; sourceTree = ""; }; + B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetEntry.swift; sourceTree = ""; }; + B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PriceWidgetEntryView.swift; sourceTree = ""; }; + B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletAppShortcuts.swift; sourceTree = ""; }; + B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BlueWalletUITest.swift; sourceTree = ""; }; + B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompactPriceView.swift; sourceTree = ""; }; + B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FiatUnitEnum.swift; sourceTree = ""; }; + B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeWidgetHelperSpec.h; sourceTree = ""; }; + B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = MenuElementsEmitter.m; sourceTree = ""; }; + B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLParserDelegate.swift; sourceTree = ""; }; + B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetHelper.swift; sourceTree = ""; }; + B4B3EC202D69FF6C00327F3D /* SegmentedControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SegmentedControlView.swift; path = ../blue_modules/Views/SegmentedControl/ios/SegmentedControlView.swift; sourceTree = SOURCE_ROOT; }; + B4B3EC232D69FF8700327F3D /* EventEmitter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventEmitter.swift; sourceTree = ""; }; + B4B3EF002D6AFF6C003270A0 /* SegmentedControlManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SegmentedControlManager.swift; path = ../blue_modules/Views/SegmentedControl/ios/SegmentedControlManager.swift; sourceTree = SOURCE_ROOT; }; + B4B3EF1F2D6CFF6C003270A0 /* SegmentedControlBridge.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = SegmentedControlBridge.m; path = ../blue_modules/Views/SegmentedControl/ios/SegmentedControlBridge.m; sourceTree = SOURCE_ROOT; }; B4D3235A177F4580BA52F2F9 /* libRNCSlider.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNCSlider.a; sourceTree = ""; }; + B4EFF73A2C3F6C5E0095D655 /* MockData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockData.swift; sourceTree = ""; }; + B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = WidgetHelper.mm; sourceTree = ""; }; + B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = EventEmitter.mm; sourceTree = ""; }; + B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeEventEmitterSpec.h; sourceTree = ""; }; + B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = NativeMenuElementsEmitterSpec.h; sourceTree = ""; }; B642AFB13483418CAB6FF25E /* libRCTQRCodeLocalImage.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTQRCodeLocalImage.a; sourceTree = ""; }; B9D9B3A7B2CB4255876B67AF /* libz.tbd */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; BBA99996E6FA4B49ACE0BEFA /* libRNRate.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNRate.a; sourceTree = ""; }; - C4496FB303574862B40A878A /* AntDesign.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = AntDesign.ttf; path = "../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf"; sourceTree = ""; }; - CA741BA794714D3F80251AC9 /* Ionicons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Ionicons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf"; sourceTree = ""; }; CD746B955C55410793BB72C0 /* libRNGestureHandler.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNGestureHandler.a; sourceTree = ""; }; - CF4A4D7AAD974D67A2D62B3E /* MaterialIcons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = MaterialIcons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf"; sourceTree = ""; }; E6B44173A8854B6D85D7F933 /* libRNVectorIcons-tvOS.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = "libRNVectorIcons-tvOS.a"; sourceTree = ""; }; - E8E8CE89B3D142C6A8A56C34 /* Octicons.ttf */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; name = Octicons.ttf; path = "../node_modules/react-native-vector-icons/Fonts/Octicons.ttf"; sourceTree = ""; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; ED2971642150620600B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = Platforms/AppleTVOS.platform/Developer/SDKs/AppleTVOS12.0.sdk/System/Library/Frameworks/JavaScriptCore.framework; sourceTree = DEVELOPER_DIR; }; F6F53AFC25FB422485CB22D6 /* SystemConfiguration.framework */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; FC63C7054F1C4FDFB7A830E5 /* libRCTPrivacySnapshot.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRCTPrivacySnapshot.a; sourceTree = ""; }; FC98DC24A81A463AB8B2E6B1 /* libRNImagePicker.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNImagePicker.a; sourceTree = ""; }; FD7977067E1A496F94D8B1B7 /* libRNDeviceInfo.a */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = archive.ar; path = libRNDeviceInfo.a; sourceTree = ""; }; - FF45EB303C9601ED114589A4 /* Pods-MarketWidgetExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-MarketWidgetExtension.release.xcconfig"; path = "Pods/Target Support Files/Pods-MarketWidgetExtension/Pods-MarketWidgetExtension.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -310,16 +268,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 906451CAD44154C2950030EC /* libPods-BlueWallet.a in Frameworks */, 782F075B5DD048449E2DECE9 /* libz.tbd in Frameworks */, 764B49B1420D4AEB8109BF62 /* libsqlite3.0.tbd in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - 421830728822A20A50D8A07C /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( + C978A716948AB7DEC5B6F677 /* BuildFile in Frameworks */, + 17CDA0718F42DB2CE856C872 /* libPods-BlueWallet.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -339,22 +291,15 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B40D4E39225841ED00428FCC /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 6DFC807024EA0B6C007B8700 /* EFQRCode in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ 00E356EF1AD99517003FC87E /* BlueWalletTests */ = { isa = PBXGroup; children = ( - 00E356F21AD99517003FC87E /* BlueWalletTests.m */, 00E356F01AD99517003FC87E /* Supporting Files */, + B49038D82B8FBAD300A8164A /* BlueWalletUITest.swift */, + B4EFF73A2C3F6C5E0095D655 /* MockData.swift */, ); path = BlueWalletTests; sourceTree = ""; @@ -370,20 +315,15 @@ 13B07FAE1A68108700A75B9A /* BlueWallet */ = { isa = PBXGroup; children = ( - B461B850299599F800E431AA /* AppDelegate.h */, - B461B851299599F800E431AA /* AppDelegate.mm */, + B461B851299599F800E431AA /* AppDelegate.swift */, 32C7944323B8879D00BE2AFA /* BlueWalletRelease.entitlements */, 32F0A2502310B0910095C559 /* BlueWallet.entitlements */, - 008F07F21AC5B25A0029DE68 /* main.jsbundle */, 13B07FB51A68108700A75B9A /* Images.xcassets */, 13B07FB61A68108700A75B9A /* Info.plist */, - 13B07FB71A68108700A75B9A /* main.m */, - 32B5A3292334450100F8D608 /* Bridge.swift */, - 32B5A3282334450100F8D608 /* BlueWallet-Bridging-Header.h */, 6DF25A9E249DB97E001D06F5 /* LaunchScreen.storyboard */, - 6D32C5C42596CE2F008C077C /* EventEmitter.h */, - 6D32C5C52596CE3A008C077C /* EventEmitter.m */, 84E05A832721191B001A0D3A /* Settings.bundle */, + B4742E962CCDBE8300380EEE /* Localizable.xcstrings */, + B48283572DA0DE02007EEC62 /* BlueWallet-Bridging-Header.h */, ); name = BlueWallet; sourceTree = ""; @@ -392,8 +332,6 @@ isa = PBXGroup; children = ( B43B69BA225C46D800925B1E /* libRCTLinking.a */, - B43B69B8225C462E00925B1E /* libPods-RCTLinking.a */, - B43D046E22584C1B00FBAA95 /* libRNWatch.a */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED2971642150620600B7C4FE /* JavaScriptCore.framework */, 2D16E6891FA4F8E400B85C8A /* libReact.a */, @@ -401,12 +339,10 @@ F6F53AFC25FB422485CB22D6 /* SystemConfiguration.framework */, B9D9B3A7B2CB4255876B67AF /* libz.tbd */, 7B468CC34D5B41F3950078EF /* libsqlite3.0.tbd */, - 731973BA0AC6EA78962CE5B6 /* libPods-BlueWallet.a */, 3271B0AA236E2E0700DA766F /* NotificationCenter.framework */, 6D333B3A252FE1A3004D72DF /* WidgetKit.framework */, 6D333B3C252FE1A3004D72DF /* SwiftUI.framework */, - 98455D960744E4E5DD50BA87 /* libPods-WalletInformationAndMarketWidgetExtension.a */, - 41BD3AC9FD81723B68A63C12 /* libPods-MarketWidgetExtension.a */, + 040819EDF8BD9C50A9C83E24 /* libPods-BlueWallet.a */, ); name = Frameworks; sourceTree = ""; @@ -414,21 +350,6 @@ 4B0CACE36C3348E1BCEA92C8 /* Resources */ = { isa = PBXGroup; children = ( - C4496FB303574862B40A878A /* AntDesign.ttf */, - 44BC9E3EE0E9476A830CCCB9 /* Entypo.ttf */, - 3F7F1B8332C6439793D55B45 /* EvilIcons.ttf */, - A9166D490AEF4938BD6621CF /* Feather.ttf */, - 334051161886419EA186F4BA /* FontAwesome.ttf */, - 5A8F67CF29564E41882ECEF8 /* FontAwesome5_Brands.ttf */, - 47564776A7A3427DB36C087D /* FontAwesome5_Regular.ttf */, - 78A87E7251D94144A71A2F67 /* FontAwesome5_Solid.ttf */, - 04466491BA2D4876A71222FC /* Foundation.ttf */, - CA741BA794714D3F80251AC9 /* Ionicons.ttf */, - 2FCC2CD6FF4448229D0CE0F3 /* MaterialCommunityIcons.ttf */, - CF4A4D7AAD974D67A2D62B3E /* MaterialIcons.ttf */, - E8E8CE89B3D142C6A8A56C34 /* Octicons.ttf */, - 4D746BBE67E84684848246E2 /* SimpleLineIcons.ttf */, - 47C436B1EF23484B8181DBEA /* Zocial.ttf */, ); name = Resources; sourceTree = ""; @@ -445,7 +366,8 @@ 6D2AA8062568B8E50090B089 /* Fiat */ = { isa = PBXGroup; children = ( - 6DD410AD266CAF1F0087DE03 /* fiatUnits.json */, + B440340E2BCC40A400162242 /* fiatUnits.json */, + B4AB225C2B02AD12001F4328 /* XMLParserDelegate.swift */, 6D2AA8072568B8F40090B089 /* FiatUnit.swift */, ); path = Fiat; @@ -454,6 +376,11 @@ 6D6CA4BB255872E3009312A5 /* PriceWidget */ = { isa = PBXGroup; children = ( + B49A28BA2CD18999006B08E4 /* CompactPriceView.swift */, + B48630DF2CCEE7C800A8425C /* PriceWidgetEntryView.swift */, + B48630DC2CCEE7AC00A8425C /* PriceWidgetEntry.swift */, + B48630D52CCEE67100A8425C /* PriceWidgetProvider.swift */, + B48630D02CCEE3B300A8425C /* PriceIntent.swift */, 6D6CA4BC255872E3009312A5 /* PriceWidget.swift */, ); path = PriceWidget; @@ -478,6 +405,7 @@ 6DD4109F266CADF10087DE03 /* Widgets */ = { isa = PBXGroup; children = ( + B48630EB2CCEEEA700A8425C /* WalletAppShortcuts.swift */, 6DD410C3266CCB780087DE03 /* WidgetsExtension.entitlements */, 6DD410A0266CADF10087DE03 /* Widgets.swift */, 6DD410A4266CADF40087DE03 /* Info.plist */, @@ -503,16 +431,8 @@ 6DEB4BC1254FB98300E9F9AA /* Shared */ = { isa = PBXGroup; children = ( - 6D2AA8062568B8E50090B089 /* Fiat */, + B49A28BD2CD189B0006B08E4 /* FiatUnitEnum.swift */, 6DEB4DD82552260200E9F9AA /* Views */, - 6D9A2E6A254BAB1B007B5B82 /* WidgetAPI.swift */, - 6D6CA5142558EBA3009312A5 /* WidgetAPI+Electrum.swift */, - 6D9A2E6B254BAB1B007B5B82 /* WidgetDataStore.swift */, - 6DA7047D254E24D5005FE5E2 /* UserDefaultsGroup.swift */, - 6DEB4BFA254FBA0E00E9F9AA /* Models.swift */, - 6DEB4C3A254FBF4800E9F9AA /* Colors.swift */, - 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */, - 6D4AF18225D215D0009DD853 /* BlueWalletWatch-Bridging-Header.h */, B40FC3F829CCD1AC0007EBAC /* SwiftTCPClient.swift */, ); path = Shared; @@ -532,17 +452,22 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + B49D99932EFE2F3500A718AC /* NativeWidgetHelperSpec.h */, + B4F0A4A52FA1BC0000AAAA05 /* NativeEventEmitterSpec.h */, + B4F0A4A62FA1BC0000AAAA06 /* NativeMenuElementsEmitterSpec.h */, + B45010A12C1504E900619044 /* Components */, + B44033C82BCC34AC00162242 /* Shared */, + B41C2E552BB3DCB8000FE097 /* PrivacyInfo.xcprivacy */, 13B07FAE1A68108700A75B9A /* BlueWallet */, 00E356EF1AD99517003FC87E /* BlueWalletTests */, - B40D4E31225841EC00428FCC /* BlueWalletWatch */, - B40D4E40225841ED00428FCC /* BlueWalletWatch Extension */, 6D2A6462258BA92C0092292B /* Stickers */, 6DD4109F266CADF10087DE03 /* Widgets */, + B47B21EA2B2128B8001F6690 /* BlueWalletUITests */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, B40FE50A21FAD228005D5578 /* Recovered References */, 4B0CACE36C3348E1BCEA92C8 /* Resources */, - A9B365F08E5E8EADC056DBC4 /* Pods */, + FAA856B639C61E61D2CF90A8 /* Pods */, ); indentWidth = 2; sourceTree = ""; @@ -553,59 +478,12 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* BlueWallet.app */, - B40D4E30225841EC00428FCC /* BlueWalletWatch.app */, - B40D4E3C225841ED00428FCC /* BlueWalletWatch Extension.appex */, 6D2A6461258BA92C0092292B /* Stickers.appex */, 6DD4109C266CADF10087DE03 /* WidgetsExtension.appex */, ); name = Products; sourceTree = ""; }; - A9B365F08E5E8EADC056DBC4 /* Pods */ = { - isa = PBXGroup; - children = ( - 9B3A324B70BC8C6D9314FD4F /* Pods-BlueWallet.debug.xcconfig */, - B459EE96941AE09BCB547DC0 /* Pods-BlueWallet.release.xcconfig */, - 367FA8CEB35BC9431019D98A /* Pods-MarketWidgetExtension.debug.xcconfig */, - FF45EB303C9601ED114589A4 /* Pods-MarketWidgetExtension.release.xcconfig */, - AA7DCFB2C7887DF26EDB5710 /* Pods-WalletInformationAndMarketWidgetExtension.debug.xcconfig */, - 7BAA8F97E61B677D33CF1944 /* Pods-WalletInformationAndMarketWidgetExtension.release.xcconfig */, - ); - name = Pods; - sourceTree = ""; - }; - B40D4E31225841EC00428FCC /* BlueWalletWatch */ = { - isa = PBXGroup; - children = ( - 6D203C2025D4ED2500493AD1 /* BlueWalletWatch.entitlements */, - B40D4E32225841EC00428FCC /* Interface.storyboard */, - B40D4E35225841ED00428FCC /* Assets.xcassets */, - B40D4E37225841ED00428FCC /* Info.plist */, - ); - path = BlueWalletWatch; - sourceTree = ""; - }; - B40D4E40225841ED00428FCC /* BlueWalletWatch Extension */ = { - isa = PBXGroup; - children = ( - 32F0A24F2310B0700095C559 /* BlueWalletWatch Extension.entitlements */, - B43D03242258474500FBAA95 /* Objects */, - B40D4E672258426B00428FCC /* KeychainSwiftDistrib.swift */, - 32F0A2992311DBB20095C559 /* ComplicationController.swift */, - B40D4E43225841ED00428FCC /* ExtensionDelegate.swift */, - B40D4E45225841ED00428FCC /* NotificationController.swift */, - B40D4E552258425400428FCC /* InterfaceController.swift */, - B40D4E562258425400428FCC /* NumericKeypadInterfaceController.swift */, - B40D4E5B2258425500428FCC /* ReceiveInterfaceController.swift */, - 6DFC807124EA2FA9007B8700 /* ViewQRCodefaceController.swift */, - B40D4E582258425400428FCC /* SpecifyInterfaceController.swift */, - B40D4E5C2258425500428FCC /* WalletDetailsInterfaceController.swift */, - B40D4E49225841ED00428FCC /* Info.plist */, - B40D4E4A225841ED00428FCC /* PushNotificationPayload.apns */, - ); - path = "BlueWalletWatch Extension"; - sourceTree = ""; - }; B40FE50A21FAD228005D5578 /* Recovered References */ = { isa = PBXGroup; children = ( @@ -637,18 +515,73 @@ name = "Recovered References"; sourceTree = ""; }; - B43D03242258474500FBAA95 /* Objects */ = { + B44033C82BCC34AC00162242 /* Shared */ = { + isa = PBXGroup; + children = ( + B4793DBA2CEDACBD00C92C2E /* Chain.swift */, + B450109A2C0FCD7E00619044 /* Utilities */, + 6D2AA8062568B8E50090B089 /* Fiat */, + 6D9A2E6A254BAB1B007B5B82 /* MarketAPI.swift */, + 6D6CA5142558EBA3009312A5 /* MarketAPI+Electrum.swift */, + B44033C92BCC350A00162242 /* Currency.swift */, + 6DA7047D254E24D5005FE5E2 /* UserDefaultsGroup.swift */, + 6DEB4C3A254FBF4800E9F9AA /* Colors.swift */, + 6D4AF18325D215D1009DD853 /* UserDefaultsExtension.swift */, + B44033D22BCC368800162242 /* UserDefaultsGroupKey.swift */, + B44033DC2BCC36C300162242 /* LatestTransaction.swift */, + B44033E32BCC36FF00162242 /* WalletData.swift */, + B44033E82BCC371A00162242 /* MarketData.swift */, + B44033ED2BCC374500162242 /* Numeric+abbreviated.swift */, + B44033F32BCC377F00162242 /* WidgetData.swift */, + B44033F82BCC379200162242 /* WidgetDataStore.swift */, + B44033FF2BCC37F800162242 /* Bundle+decode.swift */, + 6DEB4BFA254FBA0E00E9F9AA /* Placeholders.swift */, + B44033BE2BCC32F800162242 /* BitcoinUnit.swift */, + B44033C32BCC332400162242 /* Balance.swift */, + ); + path = Shared; + sourceTree = ""; + }; + B450109A2C0FCD7E00619044 /* Utilities */ = { + isa = PBXGroup; + children = ( + B4793DC22CEDAD4400C92C2E /* KeychainHelper.swift */, + B450109B2C0FCD8A00619044 /* Utilities.swift */, + ); + path = Utilities; + sourceTree = ""; + }; + B45010A12C1504E900619044 /* Components */ = { + isa = PBXGroup; + children = ( + B4AA75232DAA339E00CF5CBE /* MenuElementsEmitter.m */, + B4B3EC232D69FF8700327F3D /* EventEmitter.swift */, + B4F0A4A32FA1BC0000AAAA02 /* EventEmitter.mm */, + B409AB052D71E07500BA06F8 /* MenuElementsEmitter.swift */, + B4B3EC202D69FF6C00327F3D /* SegmentedControlView.swift */, + B4B3EF002D6AFF6C003270A0 /* SegmentedControlManager.swift */, + B4B3EF1F2D6CFF6C003270A0 /* SegmentedControlBridge.m */, + B4B1A4612BFA73110072E3BB /* WidgetHelper.swift */, + B4F0A4A12FA1BC0000AAAA00 /* WidgetHelper.mm */, + ); + path = Components; + sourceTree = ""; + }; + B47B21EA2B2128B8001F6690 /* BlueWalletUITests */ = { isa = PBXGroup; children = ( - B43D0374225847C500FBAA95 /* Transaction.swift */, - B43D0375225847C500FBAA95 /* TransactionTableRow.swift */, - B43D0376225847C500FBAA95 /* Wallet.swift */, - B43D0372225847C500FBAA95 /* WalletGradient.swift */, - B43D0377225847C500FBAA95 /* WalletInformation.swift */, - B43D0373225847C500FBAA95 /* WatchDataSource.swift */, - 849047C92702A32A008EE567 /* Handoff.swift */, - ); - path = Objects; + B47B21EB2B2128B8001F6690 /* BlueWalletUITests.swift */, + ); + path = BlueWalletUITests; + sourceTree = ""; + }; + FAA856B639C61E61D2CF90A8 /* Pods */ = { + isa = PBXGroup; + children = ( + 1AE7FA8B4A18928E917F42D1 /* Pods-BlueWallet.debug.xcconfig */, + 93D74F9C8EE7B4443A49594C /* Pods-BlueWallet.release.xcconfig */, + ); + path = Pods; sourceTree = ""; }; /* End PBXGroup section */ @@ -658,22 +591,20 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "BlueWallet" */; buildPhases = ( - 6F7747C31A9EE6DDC5108476 /* [CP] Check Pods Manifest.lock */, + 3B467A525D105B531AB91B81 /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, - B40D4E2D225841C300428FCC /* Embed Watch Content */, - 3271B0B6236E2E0700DA766F /* Embed App Extensions */, - C18D00A61007A84C9887DEDE /* [CP] Copy Pods Resources */, - 68CD4C52AC5B27E333599B5C /* [CP] Embed Pods Frameworks */, + 3271B0B6236E2E0700DA766F /* Embed Foundation Extensions */, A8D9893AE3CD454A9094B651 /* Upload source maps to Bugsnag */, 4B36CFF6FE55027DCA5CB6E1 /* Upload Bugsnag dSYM */, + BFE56A9A22A21E360BF7A1EC /* [CP] Embed Pods Frameworks */, + D0E81659D2FBFDD27024CF05 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( - B40D4E4C225841ED00428FCC /* PBXTargetDependency */, 6D9946682555A661000E52E8 /* PBXTargetDependency */, 6D2A6467258BA92D0092292B /* PBXTargetDependency */, 6DD410A6266CADF40087DE03 /* PBXTargetDependency */, @@ -717,52 +648,15 @@ productReference = 6DD4109C266CADF10087DE03 /* WidgetsExtension.appex */; productType = "com.apple.product-type.app-extension"; }; - B40D4E2F225841EC00428FCC /* BlueWalletWatch */ = { - isa = PBXNativeTarget; - buildConfigurationList = B40D4E52225841ED00428FCC /* Build configuration list for PBXNativeTarget "BlueWalletWatch" */; - buildPhases = ( - B40D4E2E225841EC00428FCC /* Resources */, - B40D4E51225841ED00428FCC /* Embed App Extensions */, - 421830728822A20A50D8A07C /* Frameworks */, - ); - buildRules = ( - ); - dependencies = ( - B40D4E3F225841ED00428FCC /* PBXTargetDependency */, - ); - name = BlueWalletWatch; - productName = BlueWalletWatch; - productReference = B40D4E30225841EC00428FCC /* BlueWalletWatch.app */; - productType = "com.apple.product-type.application.watchapp2"; - }; - B40D4E3B225841ED00428FCC /* BlueWalletWatch Extension */ = { - isa = PBXNativeTarget; - buildConfigurationList = B40D4E4E225841ED00428FCC /* Build configuration list for PBXNativeTarget "BlueWalletWatch Extension" */; - buildPhases = ( - B40D4E38225841ED00428FCC /* Sources */, - B40D4E39225841ED00428FCC /* Frameworks */, - B40D4E3A225841ED00428FCC /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = "BlueWalletWatch Extension"; - packageProductDependencies = ( - 6DFC806F24EA0B6C007B8700 /* EFQRCode */, - ); - productName = "BlueWalletWatch Extension"; - productReference = B40D4E3C225841ED00428FCC /* BlueWalletWatch Extension.appex */; - productType = "com.apple.product-type.watchkit2-extension"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 83CBB9F71A601CBA00E9B192 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 1250; - LastUpgradeCheck = 1020; + BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 1500; + LastUpgradeCheck = 1620; ORGANIZATIONNAME = BlueWallet; TargetAttributes = { 13B07F861A680F5B00A75B9A = { @@ -779,29 +673,13 @@ 6DD4109B266CADF10087DE03 = { CreatedOnToolsVersion = 12.5; }; - B40D4E2F225841EC00428FCC = { - CreatedOnToolsVersion = 10.2; - DevelopmentTeam = A7W54YZ4WU; - LastSwiftMigration = 1240; - }; - B40D4E3B225841ED00428FCC = { - CreatedOnToolsVersion = 10.2; - DevelopmentTeam = A7W54YZ4WU; - LastSwiftMigration = 1130; - SystemCapabilities = { - com.apple.Keychain = { - enabled = 0; - }; - }; - }; }; }; buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "BlueWallet" */; - compatibilityVersion = "Xcode 3.2"; - developmentRegion = English; + compatibilityVersion = "Xcode 15.3"; + developmentRegion = en_US; hasScannedForEncodings = 0; knownRegions = ( - English, en, Base, af, @@ -826,18 +704,16 @@ uk, tr, xh, + nb, + en_US, + "en-US", ); mainGroup = 83CBB9F61A601CBA00E9B192; - packageReferences = ( - 6DFC806E24EA0B6C007B8700 /* XCRemoteSwiftPackageReference "EFQRCode" */, - ); productRefGroup = 83CBBA001A601CBA00E9B192 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* BlueWallet */, - B40D4E2F225841EC00428FCC /* BlueWalletWatch */, - B40D4E3B225841ED00428FCC /* BlueWalletWatch Extension */, 6D2A6460258BA92C0092292B /* Stickers */, 6DD4109B266CADF10087DE03 /* WidgetsExtension */, ); @@ -850,7 +726,10 @@ buildActionMask = 2147483647; files = ( 6DF25A9F249DB97E001D06F5 /* LaunchScreen.storyboard in Resources */, + B440340F2BCC40A400162242 /* fiatUnits.json in Resources */, 84E05A842721191B001A0D3A /* Settings.bundle in Resources */, + B4742E972CCDBE8300380EEE /* Localizable.xcstrings in Resources */, + B41C2E562BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -859,6 +738,7 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B4742E9A2CCDBE8300380EEE /* Localizable.xcstrings in Resources */, 6D2A6464258BA92D0092292B /* Stickers.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -867,27 +747,10 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( + B41C2E582BB3DCB8000FE097 /* PrivacyInfo.xcprivacy in Resources */, + B44034112BCC40A400162242 /* fiatUnits.json in Resources */, + B4742E9B2CCDBE8300380EEE /* Localizable.xcstrings in Resources */, 6DD410B7266CAF5C0087DE03 /* Assets.xcassets in Resources */, - 6DD410AE266CAF1F0087DE03 /* fiatUnits.json in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B40D4E2E225841EC00428FCC /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B40D4E36225841ED00428FCC /* Assets.xcassets in Resources */, - E5D4794C26781FC1007838C1 /* fiatUnits.json in Resources */, - B40D4E34225841EC00428FCC /* Interface.storyboard in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B40D4E3A225841ED00428FCC /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B4EE583C226703320003363C /* Assets.xcassets in Resources */, - E5D4794B26781FC0007838C1 /* fiatUnits.json in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -899,16 +762,20 @@ buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( ); name = "Bundle React Native code and images"; + outputFileListPaths = ( + ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh\n"; + shellScript = "export EXTRA_PACKAGER_ARGS=\"--sourcemap-output $TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\"\nexport NODE_BINARY=$(which node)\n../node_modules/react-native/scripts/react-native-xcode.sh\n\n"; }; - 4B36CFF6FE55027DCA5CB6E1 /* Upload Bugsnag dSYM */ = { + 3B467A525D105B531AB91B81 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -916,40 +783,21 @@ inputFileListPaths = ( ); inputPaths = ( - "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}", - "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", ); - name = "Upload Bugsnag dSYM"; + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( ); outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = "/usr/bin/env ruby"; - shellScript = "api_key = nil # Insert your key here to use it directly from this script\n\n# Attempt to get the API key from an environment variable\nunless api_key\n api_key = ENV[\"BUGSNAG_API_KEY\"]\n\n # If not present, attempt to lookup the value from the Info.plist\n unless api_key\n info_plist_path = \"#{ENV[\"BUILT_PRODUCTS_DIR\"]}/#{ENV[\"INFOPLIST_PATH\"]}\"\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :bugsnag:apiKey\" \"#{info_plist_path}\"`\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :BugsnagAPIKey\" \"#{info_plist_path}\"` if !$?.success?\n api_key = plist_buddy_response if $?.success?\n end\nend\n\nfail(\"No Bugsnag API key detected - add your key to your Info.plist, BUGSNAG_API_KEY environment variable or this Run Script phase\") unless api_key\n\nfork do\n Process.setsid\n STDIN.reopen(\"/dev/null\")\n STDOUT.reopen(\"/dev/null\", \"a\")\n STDERR.reopen(\"/dev/null\", \"a\")\n\n require 'shellwords'\n\n Dir[\"#{ENV[\"DWARF_DSYM_FOLDER_PATH\"]}/*/Contents/Resources/DWARF/*\"].each do |dsym|\n curl_command = \"curl --http1.1 -F dsym=@#{Shellwords.escape(dsym)} -F projectRoot=#{Shellwords.escape(ENV[\"PROJECT_DIR\"])} \"\n curl_command += \"-F apiKey=#{Shellwords.escape(api_key)} \"\n curl_command += \"https://upload.bugsnag.com/\"\n system(curl_command)\n end\nend\n"; - showEnvVarsInLog = 0; - }; - 68CD4C52AC5B27E333599B5C /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-frameworks.sh", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes", - "${PODS_XCFRAMEWORKS_BUILD_DIR}/rn-ldk/LDKFramework.framework/LDKFramework", - ); - name = "[CP] Embed Pods Frameworks"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LDKFramework.framework", + "$(DERIVED_FILE_DIR)/Pods-BlueWallet-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 6F7747C31A9EE6DDC5108476 /* [CP] Check Pods Manifest.lock */ = { + 4B36CFF6FE55027DCA5CB6E1 /* Upload Bugsnag dSYM */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -957,18 +805,17 @@ inputFileListPaths = ( ); inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", + "${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}", ); - name = "[CP] Check Pods Manifest.lock"; + name = "Upload Bugsnag dSYM"; outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-BlueWallet-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellPath = "/usr/bin/env ruby"; + shellScript = "api_key = nil # Insert your key here to use it directly from this script\n\n# Attempt to get the API key from an environment variable\nunless api_key\n api_key = ENV[\"BUGSNAG_API_KEY\"]\n\n # If not present, attempt to lookup the value from the Info.plist\n unless api_key\n info_plist_path = \"#{ENV[\"BUILT_PRODUCTS_DIR\"]}/#{ENV[\"INFOPLIST_PATH\"]}\"\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :bugsnag:apiKey\" \"#{info_plist_path}\"`\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :BugsnagAPIKey\" \"#{info_plist_path}\"` if !$?.success?\n api_key = plist_buddy_response if $?.success?\n end\nend\n\nfail(\"No Bugsnag API key detected - add your key to your Info.plist, BUGSNAG_API_KEY environment variable or this Run Script phase\") unless api_key\n\nfork do\n Process.setsid\n STDIN.reopen(\"/dev/null\")\n STDOUT.reopen(\"/dev/null\", \"a\")\n STDERR.reopen(\"/dev/null\", \"a\")\n\n require 'shellwords'\n\n Dir[\"#{ENV[\"DWARF_DSYM_FOLDER_PATH\"]}/*/Contents/Resources/DWARF/*\"].each do |dsym|\n curl_command = \"curl --http1.1 -F dsym=@#{Shellwords.escape(dsym)} -F projectRoot=#{Shellwords.escape(ENV[\"PROJECT_DIR\"])} \"\n curl_command += \"-F apiKey=#{Shellwords.escape(api_key)} \"\n curl_command += \"https://upload.bugsnag.com/\"\n system(curl_command)\n end\nend\n\n"; showEnvVarsInLog = 0; }; A8D9893AE3CD454A9094B651 /* Upload source maps to Bugsnag */ = { @@ -976,63 +823,34 @@ buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( ); name = "Upload source maps to Bugsnag"; + outputFileListPaths = ( + ); outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh"; + shellScript = "SOURCE_MAP=\"$TMPDIR/$(md5 -qs \"$CONFIGURATION_BUILD_DIR\")-main.jsbundle.map\" ../node_modules/@bugsnag/react-native/bugsnag-react-native-xcode.sh\n\n"; }; - C18D00A61007A84C9887DEDE /* [CP] Copy Pods Resources */ = { + BFE56A9A22A21E360BF7A1EC /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); - inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources.sh", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/AntDesign.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Entypo.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/EvilIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Feather.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Brands.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Regular.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/FontAwesome5_Solid.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Fontisto.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Foundation.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Ionicons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialCommunityIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/MaterialIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Octicons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/SimpleLineIcons.ttf", - "${PODS_ROOT}/../../node_modules/react-native-vector-icons/Fonts/Zocial.ttf", - "${PODS_CONFIGURATION_BUILD_DIR}/React-Core/AccessibilityResources.bundle", + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Copy Pods Resources"; - outputPaths = ( - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AntDesign.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Entypo.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EvilIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Feather.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Brands.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Regular.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FontAwesome5_Solid.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Fontisto.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Foundation.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Ionicons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialCommunityIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/MaterialIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Octicons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SimpleLineIcons.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Zocial.ttf", - "${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AccessibilityResources.bundle", + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; CF0725821442A3000F20E874 /* Upload Bugsnag dSYM */ = { @@ -1053,7 +871,24 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = "/usr/bin/env ruby"; - shellScript = "api_key = nil # Insert your key here to use it directly from this script\n\n# Attempt to get the API key from an environment variable\nunless api_key\n api_key = ENV[\"BUGSNAG_API_KEY\"]\n\n # If not present, attempt to lookup the value from the Info.plist\n unless api_key\n info_plist_path = \"#{ENV[\"BUILT_PRODUCTS_DIR\"]}/#{ENV[\"INFOPLIST_PATH\"]}\"\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :bugsnag:apiKey\" \"#{info_plist_path}\"`\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :BugsnagAPIKey\" \"#{info_plist_path}\"` if !$?.success?\n api_key = plist_buddy_response if $?.success?\n end\nend\n\nfail(\"No Bugsnag API key detected - add your key to your Info.plist, BUGSNAG_API_KEY environment variable or this Run Script phase\") unless api_key\n\nfork do\n Process.setsid\n STDIN.reopen(\"/dev/null\")\n STDOUT.reopen(\"/dev/null\", \"a\")\n STDERR.reopen(\"/dev/null\", \"a\")\n\n require 'shellwords'\n\n Dir[\"#{ENV[\"DWARF_DSYM_FOLDER_PATH\"]}/*/Contents/Resources/DWARF/*\"].each do |dsym|\n curl_command = \"curl --http1.1 -F dsym=@#{Shellwords.escape(dsym)} -F projectRoot=#{Shellwords.escape(ENV[\"PROJECT_DIR\"])} \"\n curl_command += \"-F apiKey=#{Shellwords.escape(api_key)} \"\n curl_command += \"https://upload.bugsnag.com/\"\n system(curl_command)\n end\nend\n"; + shellScript = "api_key = nil # Insert your key here to use it directly from this script\n\n# Attempt to get the API key from an environment variable\nunless api_key\n api_key = ENV[\"BUGSNAG_API_KEY\"]\n\n # If not present, attempt to lookup the value from the Info.plist\n unless api_key\n info_plist_path = \"#{ENV[\"BUILT_PRODUCTS_DIR\"]}/#{ENV[\"INFOPLIST_PATH\"]}\"\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :bugsnag:apiKey\" \"#{info_plist_path}\"`\n plist_buddy_response = `/usr/libexec/PlistBuddy -c \"print :BugsnagAPIKey\" \"#{info_plist_path}\"` if !$?.success?\n api_key = plist_buddy_response if $?.success?\n end\nend\n\nfail(\"No Bugsnag API key detected - add your key to your Info.plist, BUGSNAG_API_KEY environment variable or this Run Script phase\") unless api_key\n\nfork do\n Process.setsid\n STDIN.reopen(\"/dev/null\")\n STDOUT.reopen(\"/dev/null\", \"a\")\n STDERR.reopen(\"/dev/null\", \"a\")\n\n require 'shellwords'\n\n Dir[\"#{ENV[\"DWARF_DSYM_FOLDER_PATH\"]}/*/Contents/Resources/DWARF/*\"].each do |dsym|\n curl_command = \"curl --http1.1 -F dsym=@#{Shellwords.escape(dsym)} -F projectRoot=#{Shellwords.escape(ENV[\"PROJECT_DIR\"])} \"\n curl_command += \"-F apiKey=#{Shellwords.escape(api_key)} \"\n curl_command += \"https://upload.bugsnag.com/\"\n system(curl_command)\n end\nend\n\n"; + showEnvVarsInLog = 0; + }; + D0E81659D2FBFDD27024CF05 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-BlueWallet/Pods-BlueWallet-resources.sh\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -1063,10 +898,46 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 6D32C5C62596CE3A008C077C /* EventEmitter.m in Sources */, - 13B07FC11A68108700A75B9A /* main.m in Sources */, - B461B852299599F800E431AA /* AppDelegate.mm in Sources */, - 32B5A32A2334450100F8D608 /* Bridge.swift in Sources */, + B44033CA2BCC350A00162242 /* Currency.swift in Sources */, + B44033EE2BCC374500162242 /* Numeric+abbreviated.swift in Sources */, + B48630E82CCEE92400A8425C /* PriceWidget.swift in Sources */, + B44033DD2BCC36C300162242 /* LatestTransaction.swift in Sources */, + B44033FE2BCC37D700162242 /* MarketAPI.swift in Sources */, + B450109C2C0FCD8A00619044 /* Utilities.swift in Sources */, + B48630E52CCEE8B800A8425C /* PriceView.swift in Sources */, + B48630E72CCEE91900A8425C /* PriceWidgetProvider.swift in Sources */, + B4B3EC252D69FF8700327F3D /* EventEmitter.swift in Sources */, + B4F0A4A42FA1BC0000AAAA03 /* EventEmitter.mm in Sources */, + B48630ED2CCEEEB000A8425C /* WalletAppShortcuts.swift in Sources */, + B409AB062D71E07500BA06F8 /* MenuElementsEmitter.swift in Sources */, + B44033CE2BCC352900162242 /* UserDefaultsGroup.swift in Sources */, + B461B852299599F800E431AA /* AppDelegate.swift in Sources */, + B44033F42BCC377F00162242 /* WidgetData.swift in Sources */, + B49A28C52CD1A894006B08E4 /* MarketData.swift in Sources */, + B4AA75242DAA339E00CF5CBE /* MenuElementsEmitter.m in Sources */, + B49A28BF2CD18A9A006B08E4 /* FiatUnitEnum.swift in Sources */, + B44033C42BCC332400162242 /* Balance.swift in Sources */, + B48630EE2CCEEEE900A8425C /* PriceIntent.swift in Sources */, + B44034072BCC38A000162242 /* FiatUnit.swift in Sources */, + B44034002BCC37F800162242 /* Bundle+decode.swift in Sources */, + B44033E22BCC36CB00162242 /* Placeholders.swift in Sources */, + B4793DBB2CEDACBD00C92C2E /* Chain.swift in Sources */, + B4B3EC222D69FF6C00327F3D /* SegmentedControlView.swift in Sources */, + B4B3EF102D6AFF6C003270A0 /* SegmentedControlManager.swift in Sources */, + B4B3EF202D6CFF6C003270A0 /* SegmentedControlBridge.m in Sources */, + B4B1A4622BFA73110072E3BB /* WidgetHelper.swift in Sources */, + B4F0A4A22FA1BC0000AAAA01 /* WidgetHelper.mm in Sources */, + B48630E12CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */, + B44033DA2BCC369A00162242 /* Colors.swift in Sources */, + B44033D32BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */, + B49A28BC2CD18999006B08E4 /* CompactPriceView.swift in Sources */, + B44033D82BCC369500162242 /* UserDefaultsExtension.swift in Sources */, + B44033E42BCC36FF00162242 /* WalletData.swift in Sources */, + B4793DC52CEDAD4400C92C2E /* KeychainHelper.swift in Sources */, + B44033BF2BCC32F800162242 /* BitcoinUnit.swift in Sources */, + B44034052BCC389200162242 /* XMLParserDelegate.swift in Sources */, + B48630DD2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */, + B44033F92BCC379200162242 /* WidgetDataStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1075,52 +946,45 @@ buildActionMask = 2147483647; files = ( 6DD410BE266CAF5C0087DE03 /* SendReceiveButtons.swift in Sources */, - 6DD410B4266CAF5C0087DE03 /* WidgetAPI.swift in Sources */, + B48630E02CCEE7C800A8425C /* PriceWidgetEntryView.swift in Sources */, + 6DD410B4266CAF5C0087DE03 /* MarketAPI.swift in Sources */, + B4793DC32CEDAD4400C92C2E /* KeychainHelper.swift in Sources */, B40FC3FA29CCD1D00007EBAC /* SwiftTCPClient.swift in Sources */, + B48630EC2CCEEEA700A8425C /* WalletAppShortcuts.swift in Sources */, 6DD410A1266CADF10087DE03 /* Widgets.swift in Sources */, + B450109D2C0FCD9F00619044 /* Utilities.swift in Sources */, + B49A28BE2CD189B0006B08E4 /* FiatUnitEnum.swift in Sources */, 6DD410AC266CAE470087DE03 /* PriceWidget.swift in Sources */, + B4B1A4642BFA73110072E3BB /* WidgetHelper.swift in Sources */, + B44033D52BCC368800162242 /* UserDefaultsGroupKey.swift in Sources */, 6DD410B2266CAF5C0087DE03 /* WalletInformationView.swift in Sources */, + B44034022BCC37F800162242 /* Bundle+decode.swift in Sources */, + B48630D62CCEE67100A8425C /* PriceWidgetProvider.swift in Sources */, + B44033CC2BCC350A00162242 /* Currency.swift in Sources */, 6DD410B6266CAF5C0087DE03 /* PriceView.swift in Sources */, + B48630DE2CCEE7AC00A8425C /* PriceWidgetEntry.swift in Sources */, + B49A28BB2CD18999006B08E4 /* CompactPriceView.swift in Sources */, 6DD410B3266CAF5C0087DE03 /* Colors.swift in Sources */, + B44033C12BCC32F800162242 /* BitcoinUnit.swift in Sources */, 6DD410BB266CAF5C0087DE03 /* MarketView.swift in Sources */, - 6DD410B5266CAF5C0087DE03 /* WidgetDataStore.swift in Sources */, + B44033F02BCC374500162242 /* Numeric+abbreviated.swift in Sources */, + B44033DF2BCC36C300162242 /* LatestTransaction.swift in Sources */, 6DD410C0266CB1460087DE03 /* MarketWidget.swift in Sources */, + B4793DBC2CEDACBD00C92C2E /* Chain.swift in Sources */, + B4AB225E2B02AD12001F4328 /* XMLParserDelegate.swift in Sources */, + B44033F62BCC377F00162242 /* WidgetData.swift in Sources */, 6DD410BA266CAF5C0087DE03 /* FiatUnit.swift in Sources */, + B44033FB2BCC379200162242 /* WidgetDataStore.swift in Sources */, + B44033EB2BCC371A00162242 /* MarketData.swift in Sources */, 6DD410AF266CAF5C0087DE03 /* WalletInformationAndMarketWidget.swift in Sources */, - 6DD410BF266CB13D0087DE03 /* Models.swift in Sources */, + B44033C62BCC332400162242 /* Balance.swift in Sources */, + B44033E62BCC36FF00162242 /* WalletData.swift in Sources */, + 6DD410BF266CB13D0087DE03 /* Placeholders.swift in Sources */, 6DD410B0266CAF5C0087DE03 /* WalletInformationWidget.swift in Sources */, - 6DD410B1266CAF5C0087DE03 /* WidgetAPI+Electrum.swift in Sources */, + 6DD410B1266CAF5C0087DE03 /* MarketAPI+Electrum.swift in Sources */, 6DD410B9266CAF5C0087DE03 /* UserDefaultsGroup.swift in Sources */, 6DD410B8266CAF5C0087DE03 /* UserDefaultsExtension.swift in Sources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; - B40D4E38225841ED00428FCC /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - B43D037C225847C500FBAA95 /* Wallet.swift in Sources */, - 6D4AF17825D211A3009DD853 /* FiatUnit.swift in Sources */, - B43D037A225847C500FBAA95 /* Transaction.swift in Sources */, - 32F0A29A2311DBB20095C559 /* ComplicationController.swift in Sources */, - B40D4E602258425500428FCC /* SpecifyInterfaceController.swift in Sources */, - B43D0379225847C500FBAA95 /* WatchDataSource.swift in Sources */, - 849047CA2702A32A008EE567 /* Handoff.swift in Sources */, - 6D4AF16325D21185009DD853 /* WidgetDataStore.swift in Sources */, - 6DFC807224EA2FA9007B8700 /* ViewQRCodefaceController.swift in Sources */, - B40D4E46225841ED00428FCC /* NotificationController.swift in Sources */, - B40D4E5D2258425500428FCC /* InterfaceController.swift in Sources */, - B43D037B225847C500FBAA95 /* TransactionTableRow.swift in Sources */, - B43D037D225847C500FBAA95 /* WalletInformation.swift in Sources */, - 6D4AF15925D21172009DD853 /* WidgetAPI.swift in Sources */, - B40D4E642258425500428FCC /* WalletDetailsInterfaceController.swift in Sources */, - B40D4E44225841ED00428FCC /* ExtensionDelegate.swift in Sources */, - B40D4E682258426B00428FCC /* KeychainSwiftDistrib.swift in Sources */, - 6D4AF16D25D21192009DD853 /* Models.swift in Sources */, - B40D4E632258425500428FCC /* ReceiveInterfaceController.swift in Sources */, - B43D0378225847C500FBAA95 /* WalletGradient.swift in Sources */, - 6D4AF18425D215D1009DD853 /* UserDefaultsExtension.swift in Sources */, - B40D4E5E2258425500428FCC /* NumericKeypadInterfaceController.swift in Sources */, + B48630EA2CCEED8400A8425C /* PriceIntent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1129,7 +993,6 @@ /* Begin PBXTargetDependency section */ 6D2A6467258BA92D0092292B /* PBXTargetDependency */ = { isa = PBXTargetDependency; - platformFilter = ios; target = 6D2A6460258BA92C0092292B /* Stickers */; targetProxy = 6D2A6466258BA92D0092292B /* PBXContainerItemProxy */; }; @@ -1142,65 +1005,25 @@ target = 6DD4109B266CADF10087DE03 /* WidgetsExtension */; targetProxy = 6DD410A5266CADF40087DE03 /* PBXContainerItemProxy */; }; - B40D4E3F225841ED00428FCC /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = B40D4E3B225841ED00428FCC /* BlueWalletWatch Extension */; - targetProxy = B40D4E3E225841ED00428FCC /* PBXContainerItemProxy */; - }; - B40D4E4C225841ED00428FCC /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - platformFilter = ios; - target = B40D4E2F225841EC00428FCC /* BlueWalletWatch */; - targetProxy = B40D4E4B225841ED00428FCC /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ -/* Begin PBXVariantGroup section */ - B40D4E32225841EC00428FCC /* Interface.storyboard */ = { - isa = PBXVariantGroup; - children = ( - B40D4E33225841EC00428FCC /* Base */, - 6D294A7324D510AC0039E22B /* af */, - 6D294A7524D510D60039E22B /* ca */, - 6D294A7724D510E60039E22B /* zh-Hant */, - 6D294A7924D510EA0039E22B /* zh-Hans */, - 6D294A7B24D510F40039E22B /* hr */, - 6D294A7D24D5111E0039E22B /* da */, - 6D294A7F24D511640039E22B /* nl */, - 6D294A8124D511690039E22B /* es */, - 6D294A8324D511720039E22B /* fr */, - 6D294A8524D511750039E22B /* it */, - 6D294A8724D5117A0039E22B /* id */, - 6D294A8924D511800039E22B /* ja */, - 6D294A8B24D511CB0039E22B /* hu */, - 6D294A8D24D5121D0039E22B /* pt-BR */, - 6D294A8F24D512230039E22B /* pt-PT */, - 6D294A9124D512260039E22B /* ru */, - 6D294A9324D512320039E22B /* sk */, - 6D294A9524D5125F0039E22B /* th */, - 6D294A9724D512620039E22B /* vi */, - 6D294A9924D512690039E22B /* uk */, - 6D294A9B24D512770039E22B /* tr */, - 6D294A9D24D5127F0039E22B /* xh */, - ); - name = Interface.storyboard; - sourceTree = ""; - }; -/* End PBXVariantGroup section */ - /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 9B3A324B70BC8C6D9314FD4F /* Pods-BlueWallet.debug.xcconfig */; + baseConfigurationReference = 1AE7FA8B4A18928E917F42D1 /* Pods-BlueWallet.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = BlueWallet/BlueWallet.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; - DEAD_CODE_STRIPPING = NO; - DEVELOPMENT_TEAM = A7W54YZ4WU; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; + "DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU; ENABLE_BITCODE = NO; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -1210,76 +1033,93 @@ ); HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = BlueWallet/Info.plist; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_BUNDLE_IDENTIFIER).ComplicationController"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", "$(inherited)", - "$(PROJECT_DIR)", ); - MARKETING_VERSION = 6.4.9; + MARKETING_VERSION = 8.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", "-lc++", ); + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet; PRODUCT_NAME = BlueWallet; PROVISIONING_PROFILE_SPECIFIER = ""; - SUPPORTS_MACCATALYST = "$(OVERRIDE_SUPPORTS_MACCATALYST:default=YES)"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.bluewallet.bluewallet"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development io.bluewallet.bluewallet catalyst"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "BlueWallet-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.2; - TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; VERSIONING_SYSTEM = "apple-generic"; }; name = Debug; }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = B459EE96941AE09BCB547DC0 /* Pods-BlueWallet.release.xcconfig */; + baseConfigurationReference = 93D74F9C8EE7B4443A49594C /* Pods-BlueWallet.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = BlueWallet/BlueWalletRelease.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; - DEVELOPMENT_TEAM = A7W54YZ4WU; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; + "DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU; ENABLE_BITCODE = NO; "ENABLE_HARDENED_RUNTIME[sdk=macosx*]" = YES; HEADER_SEARCH_PATHS = "$(inherited)"; INFOPLIST_FILE = BlueWallet/Info.plist; + INFOPLIST_KEY_CLKComplicationPrincipalClass = "$(PRODUCT_BUNDLE_IDENTIFIER).ComplicationController"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); LIBRARY_SEARCH_PATHS = ( "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", "$(inherited)", - "$(PROJECT_DIR)", ); - MARKETING_VERSION = 6.4.9; + MARKETING_VERSION = 8.0.2; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", "-lc++", ); + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet; PRODUCT_NAME = BlueWallet; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; - SUPPORTS_MACCATALYST = "$(OVERRIDE_SUPPORTS_MACCATALYST:default=YES)"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.bluewallet.bluewallet"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match AppStore io.bluewallet.bluewallet catalyst"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = YES; SWIFT_OBJC_BRIDGING_HEADER = "BlueWallet-Bridging-Header.h"; - SWIFT_VERSION = 4.2; - TARGETED_DEVICE_FAMILY = "1,2"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2,6"; VERSIONING_SYSTEM = "apple-generic"; }; name = Release; @@ -1293,23 +1133,35 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = A7W54YZ4WU; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Stickers/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MARKETING_VERSION = 6.4.9; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", + "$(inherited)", + ); + MARKETING_VERSION = 8.0.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.Stickers; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.bluewallet.bluewallet.Stickers"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -1323,24 +1175,35 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 751; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = A7W54YZ4WU; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Stickers/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 10.0; - MARKETING_VERSION = 6.4.9; + IPHONEOS_DEPLOYMENT_TARGET = 15.6; + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", + "$(inherited)", + ); + MARKETING_VERSION = 8.0.2; MTL_FAST_MATH = YES; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.Stickers; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.bluewallet.bluewallet.Stickers"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; @@ -1355,36 +1218,49 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = WidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = A7W54YZ4WU; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; + "DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 6.4.9; + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", + "$(inherited)", + ); + MARKETING_VERSION = 8.0.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.MarketWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development io.bluewallet.bluewallet.MarketWidget"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match Development io.bluewallet.bluewallet.MarketWidget catalyst"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,6"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Debug; }; @@ -1398,35 +1274,48 @@ CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = "$(inherited)"; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CODE_SIGN_ENTITLEMENTS = WidgetsExtension.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Distribution"; + CODE_SIGN_STYLE = Manual; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 751; + CURRENT_PROJECT_VERSION = 1703279999; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = A7W54YZ4WU; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = A7W54YZ4WU; + "DEVELOPMENT_TEAM[sdk=macosx*]" = A7W54YZ4WU; GCC_C_LANGUAGE_STANDARD = gnu11; INFOPLIST_FILE = Widgets/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 14.1; + IPHONEOS_DEPLOYMENT_TARGET = 17.5; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 6.4.9; + LIBRARY_SEARCH_PATHS = ( + "$(SDKROOT)/usr/lib/swift", + "$(SDKROOT)/System/iOSSupport/usr/lib/swift", + "$(inherited)", + ); + MARKETING_VERSION = 8.0.2; MTL_FAST_MATH = YES; + PRESERVE_DEAD_CODE_INITS_AND_TERMS = YES; PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.MarketWidget; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore io.bluewallet.bluewallet.MarketWidget"; + "PROVISIONING_PROFILE_SPECIFIER[sdk=macosx*]" = "match AppStore io.bluewallet.bluewallet.MarketWidget catalyst"; SKIP_INSTALL = YES; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = YES; + SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2,6"; + TVOS_DEPLOYMENT_TARGET = 15.6; }; name = Release; }; @@ -1434,8 +1323,9 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CC = ""; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -1460,6 +1350,7 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = NO; + CXX = ""; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; @@ -1470,6 +1361,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", + _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, ); GCC_SYMBOLS_PRIVATE_EXTERN = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -1478,13 +1370,28 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD = ""; + LDPLUSPLUS = ""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_CPLUSPLUSFLAGS = ( + "$(inherited)", + "-DRCT_REMOVE_LEGACY_ARCH=1", + ); + OTHER_LDFLAGS = "$(inherited)"; REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; - SWIFT_VERSION = 4.2; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; + SWIFT_VERSION = 5.0; + USE_HERMES = true; }; name = Debug; }; @@ -1492,8 +1399,9 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CC = ""; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -1518,177 +1426,44 @@ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; COPY_PHASE_STRIP = YES; + CXX = ""; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 11.2; + IPHONEOS_DEPLOYMENT_TARGET = 13.4; + LD = ""; + LDPLUSPLUS = ""; LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\""; MTL_ENABLE_DEBUG_INFO = NO; - REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; - SDKROOT = iphoneos; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_VERSION = 4.2; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; - B40D4E4F225841ED00428FCC /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "BlueWalletWatch Extension/BlueWalletWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = "BlueWalletWatch Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( + OTHER_CFLAGS = ( "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); - MARKETING_VERSION = 6.4.9; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch.extension; - PRODUCT_NAME = "${TARGET_NAME}"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 5.0; - }; - name = Debug; - }; - B40D4E50225841ED00428FCC /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_COMPLICATION_NAME = Complication; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = "BlueWalletWatch Extension/BlueWalletWatch Extension.entitlements"; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 751; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - INFOPLIST_FILE = "BlueWalletWatch Extension/Info.plist"; - LD_RUNPATH_SEARCH_PATHS = ( + OTHER_CPLUSPLUSFLAGS = ( "$(inherited)", - "@executable_path/Frameworks", - "@executable_path/../../Frameworks", + "-DRCT_REMOVE_LEGACY_ARCH=1", ); - MARKETING_VERSION = 6.4.9; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch.extension; - PRODUCT_NAME = "${TARGET_NAME}"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; + OTHER_LDFLAGS = "$(inherited)"; + REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; + SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_ENABLE_EXPLICIT_MODULES = NO; SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 5.0; - }; - name = Release; - }; - B40D4E53225841ED00428FCC /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = BlueWalletWatch/BlueWalletWatch.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 751; - DEBUG_INFORMATION_FORMAT = dwarf; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - IBSC_MODULE = BlueWalletWatch_Extension; - INFOPLIST_FILE = BlueWalletWatch/Info.plist; - MARKETING_VERSION = 6.4.9; - MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; - SWIFT_OBJC_BRIDGING_HEADER = "WalletInformationWidget/Widgets/Shared/BlueWalletWatch-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 4.2; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 5.0; - }; - name = Debug; - }; - B40D4E54225841ED00428FCC /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - CLANG_ANALYZER_NONNULL = YES; - CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; - CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; - CLANG_ENABLE_MODULES = YES; - CLANG_ENABLE_OBJC_WEAK = YES; - CLANG_WARN_DOCUMENTATION_COMMENTS = YES; - CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; - CODE_SIGN_ENTITLEMENTS = BlueWalletWatch/BlueWalletWatch.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 751; - DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = ""; - GCC_C_LANGUAGE_STANDARD = gnu11; - IBSC_MODULE = BlueWalletWatch_Extension; - INFOPLIST_FILE = BlueWalletWatch/Info.plist; - MARKETING_VERSION = 6.4.9; - MTL_FAST_MATH = YES; - PRODUCT_BUNDLE_IDENTIFIER = io.bluewallet.bluewallet.watch; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = watchos; - SKIP_INSTALL = YES; - SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OBJC_BRIDGING_HEADER = "WalletInformationWidget/Widgets/Shared/BlueWalletWatch-Bridging-Header.h"; - SWIFT_OPTIMIZATION_LEVEL = "-O"; - SWIFT_VERSION = 4.2; - TARGETED_DEVICE_FAMILY = 4; - WATCHOS_DEPLOYMENT_TARGET = 5.0; + USE_HERMES = true; + VALIDATE_PRODUCT = YES; }; name = Release; }; @@ -1731,44 +1506,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B40D4E4E225841ED00428FCC /* Build configuration list for PBXNativeTarget "BlueWalletWatch Extension" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B40D4E4F225841ED00428FCC /* Debug */, - B40D4E50225841ED00428FCC /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; - B40D4E52225841ED00428FCC /* Build configuration list for PBXNativeTarget "BlueWalletWatch" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B40D4E53225841ED00428FCC /* Debug */, - B40D4E54225841ED00428FCC /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 6DFC806E24EA0B6C007B8700 /* XCRemoteSwiftPackageReference "EFQRCode" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/EFPrefix/EFQRCode.git"; - requirement = { - kind = exactVersion; - version = 6.2.2; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - -/* Begin XCSwiftPackageProductDependency section */ - 6DFC806F24EA0B6C007B8700 /* EFQRCode */ = { - isa = XCSwiftPackageProductDependency; - package = 6DFC806E24EA0B6C007B8700 /* XCRemoteSwiftPackageReference "EFQRCode" */; - productName = EFQRCode; - }; -/* End XCSwiftPackageProductDependency section */ }; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; } diff --git a/ios/BlueWallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/BlueWallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 00000000000..23bedbaa34e --- /dev/null +++ b/ios/BlueWallet.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "89509f555bc90a15b96ca0a326a69850770bdaac04a46f9cf482d81533702e3c", + "pins" : [ + { + "identity" : "bugsnag-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/bugsnag/bugsnag-cocoa", + "state" : { + "revision" : "16b9145fc66e5296f16e733f6feb5d0e450574e8", + "version" : "6.28.1" + } + }, + { + "identity" : "efqrcode", + "kind" : "remoteSourceControl", + "location" : "https://github.com/EFPrefix/EFQRCode.git", + "state" : { + "revision" : "2991c2f318ad9529d93b2a73a382a3f9c72c64ce", + "version" : "6.2.2" + } + }, + { + "identity" : "swift_qrcodejs", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ApolloZhu/swift_qrcodejs.git", + "state" : { + "revision" : "374dc7f7b9e76c6aeb393f6a84590c6d387e1ecb", + "version" : "2.2.2" + } + } + ], + "version" : 3 +} diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet-tvOS.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet-tvOS.xcscheme deleted file mode 100644 index 147e3b955e8..00000000000 --- a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet-tvOS.xcscheme +++ /dev/null @@ -1,129 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet.xcscheme index ea2ea41c304..81cc58e2ed9 100644 --- a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet.xcscheme +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWallet.xcscheme @@ -1,49 +1,25 @@ - - - - - - - - - - + skipped = "NO" + parallelizable = "YES"> @@ -69,13 +45,6 @@ ReferencedContainer = "container:BlueWallet.xcodeproj"> - - - - - + - + diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch (Notification).xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch (Notification).xcscheme deleted file mode 100644 index ce67c26c8a4..00000000000 --- a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch (Notification).xcscheme +++ /dev/null @@ -1,105 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme deleted file mode 100644 index 772e95fd06f..00000000000 --- a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/BlueWalletWatch.xcscheme +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/MarketWidget.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/MarketWidget.xcscheme new file mode 100644 index 00000000000..c4f73be2aa7 --- /dev/null +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/MarketWidget.xcscheme @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/PriceWidget.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/PriceWidget.xcscheme new file mode 100644 index 00000000000..35e5cca60be --- /dev/null +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/PriceWidget.xcscheme @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme index 7646ef3c3c9..4f3b7c4da90 100644 --- a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/Stickers.xcscheme @@ -1,7 +1,6 @@ + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> + + + + diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationAndMarketWidget.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationAndMarketWidget.xcscheme new file mode 100644 index 00000000000..e9daaeba9c2 --- /dev/null +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationAndMarketWidget.xcscheme @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationWidget.xcscheme b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationWidget.xcscheme new file mode 100644 index 00000000000..7cd33822a23 --- /dev/null +++ b/ios/BlueWallet.xcodeproj/xcshareddata/xcschemes/WalletInformationWidget.xcscheme @@ -0,0 +1,136 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/BlueWallet.xcodeproj/xcuserdata/marcosrodriguez.xcuserdatad/xcschemes/xcschememanagement.plist b/ios/BlueWallet.xcodeproj/xcuserdata/marcosrodriguez.xcuserdatad/xcschemes/xcschememanagement.plist index f35b236dc92..96bd1a6c198 100644 --- a/ios/BlueWallet.xcodeproj/xcuserdata/marcosrodriguez.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/ios/BlueWallet.xcodeproj/xcuserdata/marcosrodriguez.xcuserdatad/xcschemes/xcschememanagement.plist @@ -4,50 +4,40 @@ SchemeUserState - BlueWallet for Apple Watch (Notification).xcscheme_^#shared#^_ + BlueWallet copy.xcscheme_^#shared#^_ orderHint - 78 + 113 - BlueWallet for Apple Watch.xcscheme_^#shared#^_ - - orderHint - 71 - - BlueWallet-tvOS.xcscheme_^#shared#^_ + BlueWallet.xcscheme_^#shared#^_ orderHint 0 - BlueWallet.xcscheme_^#shared#^_ + MarketWidget.xcscheme_^#shared#^_ orderHint - 1 + 4 - BlueWalletWatch (Glance).xcscheme_^#shared#^_ + PriceWidget.xcscheme_^#shared#^_ orderHint - 14 + 5 - BlueWalletWatch (Notification).xcscheme_^#shared#^_ + Stickers.xcscheme_^#shared#^_ orderHint - 3 + 1 - BlueWalletWatch.xcscheme_^#shared#^_ + WalletInformationAndMarketWidget.xcscheme_^#shared#^_ orderHint 2 - Stickers.xcscheme_^#shared#^_ - - orderHint - 4 - - WidgetsExtension.xcscheme_^#shared#^_ + WalletInformationWidget.xcscheme_^#shared#^_ orderHint - 144 + 3 SuppressBuildableAutocreation @@ -67,7 +57,12 @@ primary - B40D4E2F225841EC00428FCC + B47B21E82B2128B8001F6690 + + primary + + + B4A29A212B55C990002A67DF primary diff --git a/ios/BlueWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ios/BlueWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index f95d1c169d3..00000000000 --- a/ios/BlueWallet.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,23 +0,0 @@ -{ - "pins" : [ - { - "identity" : "efqrcode", - "kind" : "remoteSourceControl", - "location" : "https://github.com/EFPrefix/EFQRCode.git", - "state" : { - "revision" : "2991c2f318ad9529d93b2a73a382a3f9c72c64ce", - "version" : "6.2.2" - } - }, - { - "identity" : "swift_qrcodejs", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ApolloZhu/swift_qrcodejs.git", - "state" : { - "revision" : "374dc7f7b9e76c6aeb393f6a84590c6d387e1ecb", - "version" : "2.2.2" - } - } - ], - "version" : 2 -} diff --git a/ios/BlueWallet/AppDelegate.h b/ios/BlueWallet/AppDelegate.h deleted file mode 100644 index 5d2808256ca..00000000000 --- a/ios/BlueWallet/AppDelegate.h +++ /dev/null @@ -1,6 +0,0 @@ -#import -#import - -@interface AppDelegate : RCTAppDelegate - -@end diff --git a/ios/BlueWallet/AppDelegate.mm b/ios/BlueWallet/AppDelegate.mm deleted file mode 100644 index e660034cf2a..00000000000 --- a/ios/BlueWallet/AppDelegate.mm +++ /dev/null @@ -1,178 +0,0 @@ -#import -#import "AppDelegate.h" - -#import -#import -#import -#import -#import "RNQuickActionManager.h" -#import -#import -#import "EventEmitter.h" -#import -#import - -@interface AppDelegate() - -@end - -@implementation AppDelegate - -- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions -{ - [Bugsnag start]; - [self copyDeviceUID]; - [[NSUserDefaults standardUserDefaults] addObserver:self - forKeyPath:@"deviceUID" - options:NSKeyValueObservingOptionNew - context:NULL]; - [[NSUserDefaults standardUserDefaults] addObserver:self - forKeyPath:@"deviceUIDCopy" - options:NSKeyValueObservingOptionNew - context:NULL]; - self.moduleName = @"BlueWallet"; - // You can add your custom initial props in the dictionary below. - // They will be passed down to the ViewController used by React Native. - self.initialProps = @{}; - - [[RCTI18nUtil sharedInstance] allowRTL:YES]; - - UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter]; - center.delegate = self; - - return [super application:application didFinishLaunchingWithOptions:launchOptions]; -} - -- (NSURL *)sourceURLForBridge:(RCTBridge *)bridge -{ -#if DEBUG - return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@"index"]; -#else - return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"]; -#endif -} - -/// This method controls whether the `concurrentRoot`feature of React18 is turned on or off. -/// -/// @see: https://reactjs.org/blog/2022/03/29/react-v18.html -/// @note: This requires to be rendering on Fabric (i.e. on the New Architecture). -/// @return: `true` if the `concurrentRoot` feature is enabled. Otherwise, it returns `false`. -- (BOOL)concurrentRootEnabled -{ - return true; -} - - -- (void)observeValueForKeyPath:(NSString *) keyPath ofObject:(id) object change:(NSDictionary *) change context:(void *) context -{ - if([keyPath isEqual:@"deviceUID"] || [keyPath isEqual:@"deviceUIDCopy"]) - { - [self copyDeviceUID]; - } -} - -- (void)copyDeviceUID { - NSString *deviceUID = [[NSUserDefaults standardUserDefaults] stringForKey:@"deviceUID"]; - if (deviceUID && deviceUID.length > 0) { - [NSUserDefaults.standardUserDefaults setValue:deviceUID forKey:@"deviceUIDCopy"]; - } -} - -- (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity - restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler -{ - NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.io.bluewallet.bluewallet"]; - [defaults setValue:@{@"activityType": userActivity.activityType, @"userInfo": userActivity.userInfo} forKey:@"onUserActivityOpen"]; - if (userActivity.activityType == NSUserActivityTypeBrowsingWeb) { - return [RCTLinkingManager application:application - continueUserActivity:userActivity - restorationHandler:restorationHandler]; - } - else { - [EventEmitter.sharedInstance sendUserActivity:@{@"activityType": userActivity.activityType, @"userInfo": userActivity.userInfo}]; - return YES; - } -} - -- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { - return [RCTLinkingManager application:app openURL:url options:options]; -} - -- (BOOL)application:(UIApplication *)application shouldAllowExtensionPointIdentifier:(UIApplicationExtensionPointIdentifier)extensionPointIdentifier { - return NO; -} - -- (void)applicationWillTerminate:(UIApplication *)application { - [WCSession.defaultSession updateApplicationContext:@{@"isWalletsInitialized": @NO} error:nil]; - NSUserDefaults *defaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.io.bluewallet.bluewallet"]; - [defaults removeObjectForKey:@"onUserActivityOpen"]; -} - -- (void)application:(UIApplication *)application performActionForShortcutItem:(UIApplicationShortcutItem *)shortcutItem completionHandler:(void (^)(BOOL succeeded)) completionHandler { - [RNQuickActionManager onQuickActionPress:shortcutItem completionHandler:completionHandler]; -} - -//Called when a notification is delivered to a foreground app. --(void)userNotificationCenter:(UNUserNotificationCenter *)center willPresentNotification:(UNNotification *)notification withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler -{ - NSDictionary *userInfo = notification.request.content.userInfo; - [EventEmitter.sharedInstance sendNotification:userInfo]; - completionHandler(UNNotificationPresentationOptionSound | UNNotificationPresentationOptionAlert | UNNotificationPresentationOptionBadge); -} - -- (void)openSettings { - [EventEmitter.sharedInstance openSettings]; -} - -- (void)buildMenuWithBuilder:(id)builder { - [super buildMenuWithBuilder:builder]; - [builder removeMenuForIdentifier:UIMenuServices]; - [builder removeMenuForIdentifier:UIMenuFormat]; - [builder removeMenuForIdentifier:UIMenuToolbar]; - [builder removeMenuForIdentifier:UIMenuFile]; - - UIKeyCommand *settingsCommand = [UIKeyCommand keyCommandWithInput:@"," modifierFlags:UIKeyModifierCommand action:@selector(openSettings)]; - [settingsCommand setTitle:@"Settings..."]; - UIMenu *settings = [UIMenu menuWithTitle:@"Settings..." image:nil identifier:@"openSettings" options:UIMenuOptionsDisplayInline children:@[settingsCommand]]; - - [builder insertSiblingMenu:settings afterMenuForIdentifier:UIMenuAbout]; -} - - --(void)showHelp:(id)sender { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"https://bluewallet.io/docs"] options:@{} completionHandler:nil]; -} - -- (BOOL)canPerformAction:(SEL)action withSender:(id)sender { - if (action == @selector(showHelp:)) { - return true; - } else { - return [super canPerformAction:action withSender:sender]; - } -} - -// Required for the register event. -- (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken -{ - [RNCPushNotificationIOS didRegisterForRemoteNotificationsWithDeviceToken:deviceToken]; -} -// Required for the notification event. You must call the completion handler after handling the remote notification. -- (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo -fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler -{ - [RNCPushNotificationIOS didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; -} -// Required for the registrationError event. -- (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error -{ - [RNCPushNotificationIOS didFailToRegisterForRemoteNotificationsWithError:error]; -} -// Required for localNotification event -- (void)userNotificationCenter:(UNUserNotificationCenter *)center -didReceiveNotificationResponse:(UNNotificationResponse *)response - withCompletionHandler:(void (^)(void))completionHandler -{ - [RNCPushNotificationIOS didReceiveNotificationResponse:response]; -} - -@end diff --git a/ios/BlueWallet/AppDelegate.swift b/ios/BlueWallet/AppDelegate.swift new file mode 100644 index 00000000000..b0121ca9ed9 --- /dev/null +++ b/ios/BlueWallet/AppDelegate.swift @@ -0,0 +1,495 @@ +import UIKit +import React +import React_RCTAppDelegate +import ReactAppDependencyProvider +import UserNotifications +import Bugsnag + + +@main +class AppDelegate: RCTAppDelegate, UNUserNotificationCenterDelegate { + + private var userDefaultsGroup: UserDefaults? + + override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { + clearFilesIfNeeded() + + // Fix app group UserDefaults initialization + userDefaultsGroup = UserDefaults.standard + + // Set up device UID observers early + setupDeviceUIDObservers() + + let doNotTrackValue = userDefaultsGroup?.string(forKey: "donottrack") ?? "0" + NSLog("[AppDelegate] Initial Do Not Track value: '\(doNotTrackValue)'") + + if let isDoNotTrackEnabled = userDefaultsGroup?.string(forKey: "donottrack"), isDoNotTrackEnabled == "1" { + let isEnabled = userDefaultsGroup?.string(forKey: "donottrack") ?? "0" + NSLog("[AppDelegate] Do Not Track setting: \(isEnabled), expected to be '1'") + + userDefaultsGroup?.set("Disabled", forKey: "deviceUIDCopy") + userDefaultsGroup?.synchronize() + + NSLog("[AppDelegate] Do Not Track enabled: set deviceUIDCopy to 'Disabled'") + + } else { + #if targetEnvironment(macCatalyst) + let config = BugsnagConfiguration.loadConfig() + config.appType = "macOS" + Bugsnag.start(with: config) + copyDeviceUID() + #else + Bugsnag.start() + copyDeviceUID() + #endif + } + + self.moduleName = "BlueWallet" + self.dependencyProvider = RCTAppDependencyProvider() + self.initialProps = [:] + + RCTI18nUtil.sharedInstance().allowRTL(true) + + RNNotifications.startMonitorNotifications() + RNNotifications.addNativeDelegate(self) + + setupUserDefaultsListener() + registerNotificationCategories() + + // Access the singleton via the class method + _ = MenuElementsEmitter.sharedInstance() + NSLog("[MenuElements] AppDelegate: Initialized emitter singleton") + + let result = super.application(application, didFinishLaunchingWithOptions: launchOptions) + + return result + } + + override func sourceURL(for bridge: RCTBridge) -> URL? { + return bundleURL() + } + + override func bundleURL() -> URL? { + #if DEBUG + return RCTBundleURLProvider.sharedSettings().jsBundleURL(forBundleRoot: "index") + #else + return Bundle.main.url(forResource: "main", withExtension: "jsbundle") + #endif + } + + private func registerNotificationCategories() { + let viewAddressTransactionsAction = UNNotificationAction( + identifier: "VIEW_ADDRESS_TRANSACTIONS", + title: NSLocalizedString("VIEW_ADDRESS_TRANSACTIONS_TITLE", comment: ""), + options: .foreground + ) + + let viewTransactionDetailsAction = UNNotificationAction( + identifier: "VIEW_TRANSACTION_DETAILS", + title: NSLocalizedString("VIEW_TRANSACTION_DETAILS_TITLE", comment: ""), + options: .foreground + ) + + let transactionCategory = UNNotificationCategory( + identifier: "TRANSACTION_CATEGORY", + actions: [viewAddressTransactionsAction, viewTransactionDetailsAction], + intentIdentifiers: [], + options: .customDismissAction + ) + + UNUserNotificationCenter.current().setNotificationCategories([transactionCategory]) + } + + private func setupUserDefaultsListener() { + guard let defaults = userDefaultsGroup else { + NSLog("[AppDelegate] Cannot setup UserDefaults listeners: group defaults not available") + return + } + + let keys = [ + "WidgetCommunicationAllWalletsSatoshiBalance", + "WidgetCommunicationAllWalletsLatestTransactionTime", + "WidgetCommunicationDisplayBalanceAllowed", + "WidgetCommunicationLatestTransactionIsUnconfirmed", + "preferredCurrency", + "preferredCurrencyLocale", + "electrum_host", + "electrum_tcp_port", + "electrum_ssl_port" + ] + + for key in keys { + defaults.addObserver(self, forKeyPath: key, options: .new, context: nil) + } + } + + private func copyDeviceUID() { + let isDoNotTrackEnabled = userDefaultsGroup?.string(forKey: "donottrack") == "1" + + let deviceUID = UserDefaults.standard.string(forKey: "deviceUID") ?? "" + let currentCopy = userDefaultsGroup?.string(forKey: "deviceUIDCopy") ?? "" + + if isDoNotTrackEnabled { + if currentCopy != "Disabled" { + userDefaultsGroup?.set("Disabled", forKey: "deviceUIDCopy") + userDefaultsGroup?.synchronize() + NSLog("[AppDelegate] Do Not Track enabled - set deviceUIDCopy to 'Disabled'") + } + return + } + + let hasCorrectFormat = deviceUID.count == 36 && deviceUID.components(separatedBy: "-").count == 5 + if deviceUID.isEmpty || !hasCorrectFormat { + let uuid = UUID().uuidString + UserDefaults.standard.setValue(uuid, forKey: "deviceUID") + copyDeviceUID() + return + } + if deviceUID != currentCopy { + userDefaultsGroup?.set(deviceUID, forKey: "deviceUIDCopy") + userDefaultsGroup?.synchronize() + + NSLog("[AppDelegate] Synced deviceUID to shared group: \(deviceUID)") + + let updatedCopy = userDefaultsGroup?.string(forKey: "deviceUIDCopy") ?? "" + NSLog("[AppDelegate] Verification - deviceUIDCopy is now: \(updatedCopy)") + } + } + + + private func setupDeviceUIDObservers() { + UserDefaults.standard.addObserver(self, forKeyPath: "deviceUID", options: .new, context: nil) + + if userDefaultsGroup != nil { + userDefaultsGroup?.addObserver(self, forKeyPath: "donottrack", options: .new, context: nil) + NSLog("[AppDelegate] Registered observer for donottrack changes") + } + + // Check if Do Not Track is enabled + let isDoNotTrackEnabled = userDefaultsGroup?.string(forKey: "donottrack") == "1" + NSLog("[AppDelegate] Do Not Track enabled: \(isDoNotTrackEnabled)") + + let currentDeviceUID = UserDefaults.standard.string(forKey: "deviceUID") + + if !isDoNotTrackEnabled { + var shouldSetUUID = false + + if currentDeviceUID == nil { + shouldSetUUID = true + NSLog("[AppDelegate] No deviceUID exists, will create a new one") + } else if let currentUID = currentDeviceUID { + let hasCorrectFormat = currentUID.count == 36 && currentUID.components(separatedBy: "-").count == 5 + if !hasCorrectFormat { + shouldSetUUID = true + NSLog("[AppDelegate] Current deviceUID doesn't match UUID format, will replace it") + } + } + + if shouldSetUUID { + let uuid = UUID().uuidString + UserDefaults.standard.setValue(uuid, forKey: "deviceUID") + NSLog("[AppDelegate] Set deviceUID to: \(uuid)") + } + } else { + NSLog("[AppDelegate] Do Not Track enabled - not setting UUID") + } + + if userDefaultsGroup != nil { + UserDefaults.standard.addSuite(named: UserDefaultsGroupKey.GroupName.rawValue) + NSLog("[AppDelegate] Registered app group UserDefaults with standard UserDefaults") + } + + copyDeviceUID() + } + + private func clearFilesIfNeeded() { + let defaults = UserDefaults.standard + if defaults.bool(forKey: "clearFilesOnLaunch") { + clearDirectory(.documentDirectory) + clearDirectory(.cachesDirectory) + clearTempDirectory() + + defaults.set(false, forKey: "clearFilesOnLaunch") + defaults.synchronize() + + DispatchQueue.main.async { + let alert = UIAlertController( + title: "Cache Cleared", + message: "The document, cache, and temp directories have been cleared.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) + self.window.rootViewController?.present(alert, animated: true, completion: nil) + } + } + } + + private func clearDirectory(_ directory: FileManager.SearchPathDirectory) { + if let directoryURL = FileManager.default.urls(for: directory, in: .userDomainMask).last { + clearDirectory(at: directoryURL) + } + } + + private func clearTempDirectory() { + let tempDirectory = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + clearDirectory(at: tempDirectory) + } + + private func clearDirectory(at url: URL) { + do { + let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: []) + for fileURL in contents { + try FileManager.default.removeItem(at: fileURL) + } + } catch { + print("Error clearing directory: \(error.localizedDescription)") + } + } + + // MARK: - Key-Value Observing + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) { + guard let keyPath = keyPath else { return } + + // Handle deviceUID change + if keyPath == "deviceUID" { + NSLog("[AppDelegate] deviceUID changed, calling copyDeviceUID") + copyDeviceUID() + } + + // Handle donottrack changes + if keyPath == "donottrack" { + let newValue = userDefaultsGroup?.string(forKey: "donottrack") ?? "0" + NSLog("[AppDelegate] donottrack changed to: \(newValue)") + + if newValue != "1" { + let deviceUID = UserDefaults.standard.string(forKey: "deviceUID") ?? "" + let hasCorrectFormat = deviceUID.count == 36 && deviceUID.components(separatedBy: "-").count == 5 + + if deviceUID.isEmpty || !hasCorrectFormat { + let uuid = UUID().uuidString + UserDefaults.standard.setValue(uuid, forKey: "deviceUID") + NSLog("[AppDelegate] Do Not Track disabled - setting new deviceUID: \(uuid)") + } + } + + copyDeviceUID() + } + + let keys = [ + "WidgetCommunicationAllWalletsSatoshiBalance", + "WidgetCommunicationAllWalletsLatestTransactionTime", + "WidgetCommunicationDisplayBalanceAllowed", + "WidgetCommunicationLatestTransactionIsUnconfirmed", + "preferredCurrency", + "preferredCurrencyLocale", + "electrum_host", + "electrum_tcp_port", + "electrum_ssl_port" + ] + + if keys.contains(keyPath) { + WidgetHelper().reloadAllWidgets() + } + } + + override func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { + let activityType = userActivity.activityType + guard !activityType.isEmpty else { + print("[Handoff] Invalid or missing userActivity") + return false + } + + let userActivityData: [String: Any] = [ + "activityType": activityType, + "userInfo": userActivity.userInfo ?? [:] + ] + + userDefaultsGroup?.setValue(userActivityData, forKey: "onUserActivityOpen") + + if ["io.bluewallet.bluewallet.receiveonchain", "io.bluewallet.bluewallet.xpub", "io.bluewallet.bluewallet.blockexplorer"].contains(activityType) { + EventEmitter.shared().sendUserActivity(userActivityData) + return true + } + + if activityType == NSUserActivityTypeBrowsingWeb { + return RCTLinkingManager.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + print("[Handoff] Unhandled user activity type: \(activityType)") + return false + } + + override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]) -> Bool { + return RCTLinkingManager.application(app, open: url, options: options) + } + + override func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + RNNotifications.didRegisterForRemoteNotifications(withDeviceToken: deviceToken) + } + + override func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + RNNotifications.didFailToRegisterForRemoteNotificationsWithError(error) + } + + override func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + RNNotifications.didReceiveBackgroundNotification(userInfo, withCompletionHandler: completionHandler) + } + + override func applicationWillTerminate(_ application: UIApplication) { + userDefaultsGroup?.removeObject(forKey: "onUserActivityOpen") + + RNNotifications.removeNativeDelegate(self) + UserDefaults.standard.removeObserver(self, forKeyPath: "deviceUID") + } + + override func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) { + RNQuickActionManager.onQuickActionPress(shortcutItem, completionHandler: completionHandler) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.sound, .list, .banner, .badge]) + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { + let userInfo = response.notification.request.content.userInfo + let blockExplorer = userDefaultsGroup?.string(forKey: "blockExplorer") ?? "https://www.mempool.space" + + if let data = userInfo["data"] as? [String: Any] { + if response.actionIdentifier == "VIEW_ADDRESS_TRANSACTIONS", let address = data["address"] as? String { + if let url = URL(string: "\(blockExplorer)/address/\(address)") { + UIApplication.shared.open(url) + } + } else if response.actionIdentifier == "VIEW_TRANSACTION_DETAILS", let txid = data["txid"] as? String { + if let url = URL(string: "\(blockExplorer)/tx/\(txid)") { + UIApplication.shared.open(url) + } + } + } + + completionHandler() + } + + // MARK: - Menu Building (macOS Catalyst) + + override func buildMenu(with builder: UIMenuBuilder) { + super.buildMenu(with: builder) + + // Remove unnecessary menus + builder.remove(menu: .services) + builder.remove(menu: .format) + builder.remove(menu: .toolbar) + + // Remove the original Settings menu item + builder.remove(menu: .preferences) + + // File -> Add Wallet (Command + Shift + A) + let addWalletCommand = UIKeyCommand( + title: "Add Wallet", + action: #selector(addWalletAction), + input: "A", + modifierFlags: [.command, .shift] + ) + + // All menu items enabled by default + + // File -> Import Wallet (Command + I) + let importWalletCommand = UIKeyCommand( + title: "Import Wallet", + action: #selector(importWalletAction), + input: "I", + modifierFlags: .command + ) + + // Group Add Wallet and Import Wallet in a displayInline menu + let walletOperationsMenu = UIMenu( + title: "", + image: nil, + identifier: nil, + options: .displayInline, + children: [addWalletCommand, importWalletCommand] + ) + + // Modify the existing File menu to include Wallet Operations + if let fileMenu = builder.menu(for: .file) { + // Add "Reload Transactions" (Command + R) + let reloadTransactionsCommand = UIKeyCommand( + title: "Reload Transactions", + action: #selector(reloadTransactionsAction), + input: "R", + modifierFlags: .command + ) + + // Combine wallet operations and Reload Transactions into the new File menu + let newFileMenu = UIMenu( + title: fileMenu.title, + image: fileMenu.image, + identifier: fileMenu.identifier, + options: fileMenu.options, + children: [walletOperationsMenu, reloadTransactionsCommand] + ) + + builder.replace(menu: .file, with: newFileMenu) + } + + // BlueWallet -> Settings (Command + ,) + let settingsCommand = UIKeyCommand( + title: "Settings...", + action: #selector(openSettings), + input: ",", + modifierFlags: .command + ) + + let settingsMenu = UIMenu( + title: "", + image: nil, + identifier: nil, + options: .displayInline, + children: [settingsCommand] + ) + + // Insert the new Settings menu after the About menu + builder.insertSibling(settingsMenu, afterMenu: .about) + } + + @objc func openSettings(_ keyCommand: UIKeyCommand) { + DispatchQueue.main.async { + MenuElementsEmitter.sharedInstance().openSettings() + } + } + + @objc func addWalletAction(_ keyCommand: UIKeyCommand) { + DispatchQueue.main.async { + MenuElementsEmitter.sharedInstance().addWalletMenuAction() + } + } + + @objc func importWalletAction(_ keyCommand: UIKeyCommand) { + DispatchQueue.main.async { + MenuElementsEmitter.sharedInstance().importWalletMenuAction() + } + } + + @objc func reloadTransactionsAction(_ keyCommand: UIKeyCommand) { + DispatchQueue.main.async { + MenuElementsEmitter.sharedInstance().reloadTransactionsMenuAction() + } + } + + @objc func showHelp(_ sender: Any) { + if let url = URL(string: "https://bluewallet.io/docs") { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + if action == #selector(showHelp(_:)) { + return true + } else { + return super.canPerformAction(action, withSender: sender) + } + } +} diff --git a/ios/BlueWallet/BlueWallet.entitlements b/ios/BlueWallet/BlueWallet.entitlements index 84242c28ec2..b2b8e48bb08 100644 --- a/ios/BlueWallet/BlueWallet.entitlements +++ b/ios/BlueWallet/BlueWallet.entitlements @@ -16,8 +16,6 @@ com.apple.security.network.client - com.apple.security.network.server - com.apple.security.personal-information.photos-library diff --git a/ios/BlueWallet/BlueWalletRelease.entitlements b/ios/BlueWallet/BlueWalletRelease.entitlements index 3d5de67c1b1..b2b8e48bb08 100644 --- a/ios/BlueWallet/BlueWalletRelease.entitlements +++ b/ios/BlueWallet/BlueWalletRelease.entitlements @@ -4,20 +4,6 @@ aps-environment development - com.apple.developer.icloud-container-identifiers - - iCloud.io.bluewallet.bluewallet - - com.apple.developer.icloud-services - - CloudDocuments - - com.apple.developer.ubiquity-container-identifiers - - iCloud.io.bluewallet.bluewallet - - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) com.apple.security.app-sandbox com.apple.security.application-groups diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024 1.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024 1.png new file mode 100644 index 00000000000..594857ecda6 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024 1.png differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/1024.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024 2.png similarity index 100% rename from ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/1024.png rename to ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024 2.png diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024.png index 70d084bac84..9c2422d9c26 100644 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024.png and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/1024.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/128.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/128.png deleted file mode 100644 index ae965157c44..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/128.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/16.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/16.png deleted file mode 100644 index 9661e8a63c0..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/16.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256-1.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256-1.png deleted file mode 100644 index 1acf97697d3..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256-1.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256.png deleted file mode 100644 index 1acf97697d3..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/256.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32-1.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32-1.png deleted file mode 100644 index 4e67704c422..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32-1.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32.png deleted file mode 100644 index 4e67704c422..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/32.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512-1.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512-1.png deleted file mode 100644 index c9a0c65f0f7..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512-1.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512.png deleted file mode 100644 index c9a0c65f0f7..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/512.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/64.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/64.png deleted file mode 100644 index bb8fdcd79b6..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/64.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20.png deleted file mode 100644 index 20c8694e6ef..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@2x.png deleted file mode 100644 index 538ecc1cef0..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@3x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@3x.png deleted file mode 100644 index 4ebe02d9cb8..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-20@3x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29.png deleted file mode 100644 index 514920286d9..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@2x.png deleted file mode 100644 index 05dde42416a..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@3x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@3x.png deleted file mode 100644 index 8da2de5496f..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-29@3x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40.png deleted file mode 100644 index 538ecc1cef0..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@2x.png deleted file mode 100644 index c2026acdcf7..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@3x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@3x.png deleted file mode 100644 index 67eb590b45c..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-40@3x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@2x.png deleted file mode 100644 index 67eb590b45c..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@3x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@3x.png deleted file mode 100644 index 0d40abcd246..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-60@3x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76.png deleted file mode 100644 index bf200e75a0b..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76@2x.png deleted file mode 100644 index 4e0e04b75c5..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-76@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-83.5@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-83.5@2x.png deleted file mode 100644 index 40b59fb4893..00000000000 Binary files a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/BlueWallet-83.5@2x.png and /dev/null differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/Contents.json b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/Contents.json index ae17cf5c14f..d4eda105fae 100644 --- a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/Contents.json @@ -1,169 +1,91 @@ { "images" : [ { - "filename" : "BlueWallet-20@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "BlueWallet-20@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "20x20" - }, - { - "filename" : "BlueWallet-29@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "BlueWallet-29@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "29x29" - }, - { - "filename" : "BlueWallet-40@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "BlueWallet-40@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "40x40" - }, - { - "filename" : "BlueWallet-60@2x.png", - "idiom" : "iphone", - "scale" : "2x", - "size" : "60x60" - }, - { - "filename" : "BlueWallet-60@3x.png", - "idiom" : "iphone", - "scale" : "3x", - "size" : "60x60" - }, - { - "filename" : "BlueWallet-20.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "20x20" - }, - { - "filename" : "BlueWallet-20@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "20x20" - }, - { - "filename" : "BlueWallet-29.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "29x29" - }, - { - "filename" : "BlueWallet-29@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "29x29" - }, - { - "filename" : "BlueWallet-40.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "40x40" - }, - { - "filename" : "BlueWallet-40@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "40x40" - }, - { - "filename" : "BlueWallet-76.png", - "idiom" : "ipad", - "scale" : "1x", - "size" : "76x76" - }, - { - "filename" : "BlueWallet-76@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "76x76" + "filename" : "BlueWallet-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" }, { - "filename" : "BlueWallet-83.5@2x.png", - "idiom" : "ipad", - "scale" : "2x", - "size" : "83.5x83.5" + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" }, { - "filename" : "BlueWallet-1024.png", - "idiom" : "ios-marketing", - "scale" : "1x", + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "filename" : "1024 1.png", + "idiom" : "universal", + "platform" : "ios", "size" : "1024x1024" }, { - "filename" : "16.png", + "filename" : "icon_16x16.png", "idiom" : "mac", "scale" : "1x", "size" : "16x16" }, { - "filename" : "32.png", + "filename" : "icon_16x16@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "16x16" }, { - "filename" : "32-1.png", + "filename" : "icon_32x32.png", "idiom" : "mac", "scale" : "1x", "size" : "32x32" }, { - "filename" : "64.png", + "filename" : "icon_32x32@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "32x32" }, { - "filename" : "128.png", + "filename" : "icon_128x128.png", "idiom" : "mac", "scale" : "1x", "size" : "128x128" }, { - "filename" : "256.png", + "filename" : "icon_128x128@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "128x128" }, { - "filename" : "256-1.png", + "filename" : "icon_256x256.png", "idiom" : "mac", "scale" : "1x", "size" : "256x256" }, { - "filename" : "512.png", + "filename" : "icon_256x256@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "256x256" }, { - "filename" : "512-1.png", + "filename" : "icon_512x512.png", "idiom" : "mac", "scale" : "1x", "size" : "512x512" }, { - "filename" : "1024.png", + "filename" : "icon_512x512@2x.png", "idiom" : "mac", "scale" : "2x", "size" : "512x512" diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 00000000000..9222970acc0 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 00000000000..a970347b62f Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 00000000000..afb26ac6296 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 00000000000..9fa78ea4bf5 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 00000000000..a970347b62f Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 00000000000..6e2c9019380 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 00000000000..9fa78ea4bf5 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 00000000000..cc9ddb62411 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 00000000000..6e2c9019380 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 00000000000..cb6f9d6e1c3 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/Contents.json b/ios/BlueWallet/Images.xcassets/Contents.json index da4a164c918..73c00596a7f 100644 --- a/ios/BlueWallet/Images.xcassets/Contents.json +++ b/ios/BlueWallet/Images.xcassets/Contents.json @@ -1,6 +1,6 @@ { "info" : { - "version" : 1, - "author" : "xcode" + "author" : "xcode", + "version" : 1 } -} \ No newline at end of file +} diff --git a/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/Contents.json b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/Contents.json new file mode 100644 index 00000000000..add923b2afa --- /dev/null +++ b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "icon.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "icon@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "icon@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon.png b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon.png new file mode 100644 index 00000000000..4c4d57e85fb Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon.png differ diff --git a/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@2x.png b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@2x.png new file mode 100644 index 00000000000..23c1109bac5 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@2x.png differ diff --git a/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@3x.png b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@3x.png new file mode 100644 index 00000000000..84755259c91 Binary files /dev/null and b/ios/BlueWallet/Images.xcassets/SplashIcon.imageset/icon@3x.png differ diff --git a/ios/BlueWallet/Info.plist b/ios/BlueWallet/Info.plist index 3994481697f..1d58d76c7a7 100644 --- a/ios/BlueWallet/Info.plist +++ b/ios/BlueWallet/Info.plist @@ -2,12 +2,13 @@ - CADisableMinimumFrameDurationOnPhone - BGTaskSchedulerPermittedIdentifiers io.bluewallet.bluewallet.fetchTxsForWallet + com.transistorsoft.fetch + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion en CFBundleDisplayName @@ -28,6 +29,21 @@ io.bluewallet.psbt + + CFBundleTypeIconFiles + + CFBundleTypeName + Image + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.jpeg + public.image + + CFBundleTypeIconFiles @@ -56,6 +72,20 @@ io.bluewallet.backup + + CFBundleTypeIconFiles + + CFBundleTypeName + BW COSIGNER + CFBundleTypeRole + Editor + LSHandlerRank + Owner + LSItemContentTypes + + io.bluewallet.bwcosigner + + CFBundleExecutable $(EXECUTABLE_NAME) @@ -65,11 +95,6 @@ 6.0 CFBundleName $(PRODUCT_NAME) - NSUserActivityTypes - - io.bluewallet.bluewallet.receiveonchain - io.bluewallet.bluewallet.xpub - CFBundlePackageType APPL CFBundleShortVersionString @@ -93,6 +118,10 @@ CFBundleVersion $(CURRENT_PROJECT_VERSION) + FIREBASE_ANALYTICS_COLLECTION_ENABLED + + FIREBASE_MESSAGING_AUTO_INIT_ENABLED + ITSAppUsesNonExemptEncryption LSApplicationCategoryType @@ -101,14 +130,28 @@ https http + itms-apps + LSMinimumSystemVersion + 14 LSRequiresIPhoneOS LSSupportsOpeningDocumentsInPlace + NSAppIntents + + + INIntentClassName + PriceView + IntentDescription + Quickly view the current Bitcoin market rate. + IntentName + Bitcoin Price + + NSAppTransportSecurity - NSAllowsArbitraryLoads + NSAllowsLocalNetworking NSExceptionDomains @@ -140,47 +183,31 @@ - NSAppleMusicUsageDescription - This alert should not show up as we do not require this data - NSBluetoothPeripheralUsageDescription - This alert should not show up as we do not require this data - NSCalendarsUsageDescription - This alert should not show up as we do not require this data NSCameraUsageDescription - In order to quickly scan the recipient's address, we need your permission to use the camera to scan their QR Code. + In order to quickly scan the recipient's address, we need your permission to use the camera to scan their QR Code. NSFaceIDUsageDescription - In order to use FaceID please confirm your permission. - NSLocationAlwaysUsageDescription - This alert should not show up as we do not require this data - NSLocationWhenInUseUsageDescription - This alert should not show up as we do not require this data - NSMicrophoneUsageDescription - This alert should not show up as we do not require this data - NSMotionUsageDescription - This alert should not show up as we do not require this data + In order to use FaceID for authentication when performing sensitive actions, we need your permission. NSPhotoLibraryAddUsageDescription Your authorization is required to save this image. NSPhotoLibraryUsageDescription In order to import an image for scanning, we need your permission to access your photo library. - NSSpeechRecognitionUsageDescription - This alert should not show up as we do not require this data + NSUserActivityTypes + + io.bluewallet.bluewallet.receiveonchain + io.bluewallet.bluewallet.xpub + + RCTNewArchEnabled + UIAppFonts - AntDesign.ttf Entypo.ttf - EvilIcons.ttf - Feather.ttf FontAwesome.ttf - FontAwesome5_Brands.ttf - FontAwesome5_Regular.ttf - FontAwesome5_Solid.ttf - Foundation.ttf + FontAwesome6_Brands.ttf + FontAwesome6_Regular.ttf + FontAwesome6_Solid.ttf Ionicons.ttf - MaterialCommunityIcons.ttf + MaterialDesignIcons.ttf MaterialIcons.ttf - Octicons.ttf - SimpleLineIcons.ttf - Zocial.ttf UIBackgroundModes @@ -188,26 +215,36 @@ processing remote-notification + UIFileSharingEnabled + UILaunchStoryboardName LaunchScreen UIRequiredDeviceCapabilities - armv7 + arm64 UISupportedInterfaceOrientations + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait - UIInterfaceOrientationPortraitUpsideDown UISupportedInterfaceOrientations~ipad - UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~iphone-MaxScreenSizePhone + + UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight UIViewControllerBasedStatusBarAppearance - + UTExportedTypeDeclarations @@ -217,8 +254,6 @@ UTTypeDescription Partially Signed Bitcoin Transaction - UTTypeIconFiles - UTTypeIdentifier io.bluewallet.psbt UTTypeTagSpecification @@ -230,43 +265,32 @@ - UTTypeConformsTo - - public.data - UTTypeDescription - Bitcoin Transaction - UTTypeIconFiles - + BW COSIGNER UTTypeIdentifier - io.bluewallet.psbt.txn + io.bluewallet.bwcosigner UTTypeTagSpecification public.filename-extension - txn + bwcosigner - - UTImportedTypeDeclarations - UTTypeConformsTo public.data UTTypeDescription - Partially Signed Bitcoin Transaction - UTTypeIconFiles - + Bitcoin Transaction UTTypeIdentifier - io.bluewallet.psbt + io.bluewallet.psbt.txn UTTypeTagSpecification public.filename-extension - psbt + txn @@ -276,35 +300,40 @@ public.data UTTypeDescription - Bitcoin Transaction - UTTypeIconFiles - + Electrum Backup UTTypeIdentifier - io.bluewallet.psbt.txn + io.bluewallet.backup UTTypeTagSpecification public.filename-extension - txn + backup + + UTImportedTypeDeclarations + + LSHandlerRank + Alternate UTTypeConformsTo - public.data + public.text UTTypeDescription - Electrum Backup - UTTypeIconFiles - + JSON File UTTypeIdentifier - io.bluewallet.backup + public.json UTTypeTagSpecification public.filename-extension - backup + json + + public.mime-type + + application/json diff --git a/ios/BlueWallet/main.m b/ios/BlueWallet/main.m deleted file mode 100644 index c316cf816e7..00000000000 --- a/ios/BlueWallet/main.m +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import - -#import "AppDelegate.h" - -int main(int argc, char * argv[]) { - @autoreleasepool { - return UIApplicationMain(argc, argv, nil, NSStringFromClass([AppDelegate class])); - } -} diff --git a/ios/BlueWalletTests/BlueWalletTests.m b/ios/BlueWalletTests/BlueWalletTests.m deleted file mode 100644 index d7ee43a8d75..00000000000 --- a/ios/BlueWalletTests/BlueWalletTests.m +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -#import -#import - -#import -#import - -#define TIMEOUT_SECONDS 600 -#define TEXT_TO_LOOK_FOR @"Welcome to React Native!" - -@interface BlueWalletTests : XCTestCase - -@end - -@implementation BlueWalletTests - -- (BOOL)findSubviewInView:(UIView *)view matching:(BOOL(^)(UIView *view))test -{ - if (test(view)) { - return YES; - } - for (UIView *subview in [view subviews]) { - if ([self findSubviewInView:subview matching:test]) { - return YES; - } - } - return NO; -} - -- (void)testRendersWelcomeScreen -{ - UIViewController *vc = [[[RCTSharedApplication() delegate] window] rootViewController]; - NSDate *date = [NSDate dateWithTimeIntervalSinceNow:TIMEOUT_SECONDS]; - BOOL foundElement = NO; - - __block NSString *redboxError = nil; - RCTSetLogFunction(^(RCTLogLevel level, RCTLogSource source, NSString *fileName, NSNumber *lineNumber, NSString *message) { - if (level >= RCTLogLevelError) { - redboxError = message; - } - }); - - while ([date timeIntervalSinceNow] > 0 && !foundElement && !redboxError) { - [[NSRunLoop mainRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - [[NSRunLoop mainRunLoop] runMode:NSRunLoopCommonModes beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]]; - - foundElement = [self findSubviewInView:vc.view matching:^BOOL(UIView *view) { - if ([view.accessibilityLabel isEqualToString:TEXT_TO_LOOK_FOR]) { - return YES; - } - return NO; - }]; - } - - RCTSetLogFunction(RCTDefaultLogFunction); - - XCTAssertNil(redboxError, @"RedBox error: %@", redboxError); - XCTAssertTrue(foundElement, @"Couldn't find element with text '%@' in %d seconds", TEXT_TO_LOOK_FOR, TIMEOUT_SECONDS); -} - - -@end diff --git a/ios/BlueWalletTests/BlueWalletUITest.swift b/ios/BlueWalletTests/BlueWalletUITest.swift new file mode 100644 index 00000000000..449bc0538ea --- /dev/null +++ b/ios/BlueWalletTests/BlueWalletUITest.swift @@ -0,0 +1,37 @@ +// +// BlueWalletUITest.swift +// BlueWalletUITests +// +// Created by Marcos Rodriguez on 2/28/24. +// Copyright © 2024 BlueWallet. All rights reserved. +// + +import XCTest + +final class BlueWalletUITest: XCTestCase { + + override func setUpWithError() throws { + continueAfterFailure = false + } + + override func tearDownWithError() throws { + // Put teardown code here. This method is called after the invocation of each test method in the class. + } + + func testAppLaunchesAndShowsSettingsButton() throws { + let app = XCUIApplication() + app.launch() + + let settingsButton = app.buttons["SettingsButton"] + + // Wait for the settings button to appear to make sure the app has finished launching and is displaying its initial UI. + let exists = NSPredicate(format: "exists == true") + expectation(for: exists, evaluatedWith: settingsButton, handler: nil) + + // Wait for a maximum of 10 seconds for the settings button to appear + waitForExpectations(timeout: 10, handler: nil) + + // Assert that the settings button is not only present but also hittable (visible and interactable) + XCTAssertTrue(settingsButton.isHittable, "The settings button should be visible and interactable") + } +} diff --git a/ios/BlueWalletTests/MockData.swift b/ios/BlueWalletTests/MockData.swift new file mode 100644 index 00000000000..8460b865a37 --- /dev/null +++ b/ios/BlueWalletTests/MockData.swift @@ -0,0 +1,15 @@ +// +// MockData.swift +// BlueWallet +// +// Created by Marcos Rodriguez on 7/10/24. +// Copyright © 2024 BlueWallet. All rights reserved. +// + +import Foundation + +struct MockData { + static let currentMarketData = MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2023-01-01T00:00:00+00:00") + static let previousMarketData = MarketData(nextBlock: "", sats: "", price: "$9,000", rate: 9000, dateString: "2022-12-31T00:00:00+00:00") + static let noChangeMarketData = MarketData(nextBlock: "", sats: "", price: "$10,000", rate: 10000, dateString: "2023-01-01T00:00:00+00:00") +} diff --git a/ios/BlueWalletUITests/BlueWalletUITests.swift b/ios/BlueWalletUITests/BlueWalletUITests.swift new file mode 100644 index 00000000000..cbf4c98fa89 --- /dev/null +++ b/ios/BlueWalletUITests/BlueWalletUITests.swift @@ -0,0 +1,41 @@ +// +// BlueWalletUITests.swift +// BlueWalletUITests +// +// Created by Marcos Rodriguez on 12/6/23. +// Copyright © 2023 BlueWallet. All rights reserved. +// + +import XCTest + +class BlueWalletUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + super.setUp() + continueAfterFailure = false + + // Initialize the XCUIApplication instance + app = XCUIApplication() + + // Add a launch argument to differentiate between Mac Catalyst and iOS + #if targetEnvironment(macCatalyst) + app.launchArguments.append("--macCatalyst") + #else + app.launchArguments.append("--iOS") + #endif + + app.launch() + } + + func testAppLaunchesSuccessfully() { + XCTAssertEqual(app.state, .runningForeground, "App should be running in the foreground") + + #if targetEnvironment(macCatalyst) + XCTAssertTrue(app.windows.count > 0, "There should be at least one window in Mac Catalyst") + #else + XCTAssertTrue(app.buttons.count > 0, "There should be at least one button on iOS") + #endif + } +} diff --git a/ios/BlueWalletWatch Extension/BlueWalletWatch Extension.entitlements b/ios/BlueWalletWatch Extension/BlueWalletWatch Extension.entitlements deleted file mode 100644 index 0c67376ebac..00000000000 --- a/ios/BlueWalletWatch Extension/BlueWalletWatch Extension.entitlements +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/ios/BlueWalletWatch Extension/ComplicationController.swift b/ios/BlueWalletWatch Extension/ComplicationController.swift deleted file mode 100644 index 8fe257b93ad..00000000000 --- a/ios/BlueWalletWatch Extension/ComplicationController.swift +++ /dev/null @@ -1,309 +0,0 @@ -// -// ComplicationController.swift -// T WatchKit Extension -// -// Created by Marcos Rodriguez on 8/24/19. -// Copyright © 2019 Marcos Rodriguez. All rights reserved. -// - -import ClockKit - - -class ComplicationController: NSObject, CLKComplicationDataSource { - - // MARK: - Timeline Configuration - - func getSupportedTimeTravelDirections(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTimeTravelDirections) -> Void) { - handler([]) - } - - func getTimelineStartDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - handler(nil) - } - - @available(watchOSApplicationExtension 7.0, *) - func complicationDescriptors() async -> [CLKComplicationDescriptor] { - return [CLKComplicationDescriptor( - identifier: "io.bluewallet.bluewallet", - displayName: "Market Price", - supportedFamilies: CLKComplicationFamily.allCases)] - } - - func getTimelineEndDate(for complication: CLKComplication, withHandler handler: @escaping (Date?) -> Void) { - handler(nil) - } - - func getPrivacyBehavior(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationPrivacyBehavior) -> Void) { - handler(.showOnLockScreen) - } - - // MARK: - Timeline Population - - func getCurrentTimelineEntry( - for complication: CLKComplication, - withHandler handler: @escaping (CLKComplicationTimelineEntry?) -> Void) - { - let marketData: WidgetDataStore? = UserDefaults.standard.codable(forKey: MarketData.string) - let entry: CLKComplicationTimelineEntry - let date: Date - let valueLabel: String - let valueSmallLabel: String - let currencySymbol: String - let timeLabel: String - if let price = marketData?.formattedRateForComplication, let priceAbbreviated = marketData?.formattedRateForSmallComplication, let marketDatadata = marketData?.date, let lastUpdated = marketData?.formattedDate { - date = marketDatadata - valueLabel = price - timeLabel = lastUpdated - valueSmallLabel = priceAbbreviated - if let preferredFiatCurrency = UserDefaults.standard.string(forKey: "preferredFiatCurrency"), let preferredFiatUnit = fiatUnit(currency: preferredFiatCurrency) { - currencySymbol = preferredFiatUnit.symbol - } else { - currencySymbol = fiatUnit(currency: "USD")!.symbol - } - } else { - valueLabel = "--" - timeLabel = "--" - valueSmallLabel = "--" - currencySymbol = fiatUnit(currency: "USD")!.symbol - date = Date() - } - - let line2Text = CLKSimpleTextProvider(text:currencySymbol) - let line1SmallText = CLKSimpleTextProvider(text: valueSmallLabel) - - switch complication.family { - case .circularSmall: - let template = CLKComplicationTemplateCircularSmallStackText() - template.line1TextProvider = line1SmallText - template.line2TextProvider = line2Text - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - case .utilitarianSmallFlat: - let template = CLKComplicationTemplateUtilitarianSmallFlat() - if #available(watchOSApplicationExtension 6.0, *) { - template.textProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueSmallLabel) - } else { - handler(nil) - } - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - - case .utilitarianSmall: - let template = CLKComplicationTemplateUtilitarianSmallRingImage() - template.imageProvider = CLKImageProvider(onePieceImage: UIImage(named: "Complication/Utilitarian")!) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - case .graphicCircular: - if #available(watchOSApplicationExtension 6.0, *) { - let template = CLKComplicationTemplateGraphicCircularStackText() - template.line1TextProvider = line1SmallText - template.line2TextProvider = line2Text - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .modularSmall: - let template = CLKComplicationTemplateModularSmallStackText() - template.line1TextProvider = line1SmallText - template.line2TextProvider = line2Text - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - case .graphicCorner: - let template = CLKComplicationTemplateGraphicCornerStackText() - if #available(watchOSApplicationExtension 6.0, *) { - template.outerTextProvider = CLKTextProvider(format: "%@", valueSmallLabel) - template.innerTextProvider = CLKTextProvider(format: "%@", currencySymbol) - } else { - handler(nil) - } - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - case .graphicBezel: - let template = CLKComplicationTemplateGraphicBezelCircularText() - if #available(watchOSApplicationExtension 6.0, *) { - template.textProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueSmallLabel) - let imageProvider = CLKFullColorImageProvider(fullColorImage: UIImage(named: "Complication/Graphic Bezel")!) - let circularTemplate = CLKComplicationTemplateGraphicCircularImage() - circularTemplate.imageProvider = imageProvider - template.circularTemplate = circularTemplate - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .utilitarianLarge: - if #available(watchOSApplicationExtension 7.0, *) { - let textProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueLabel) - let template = CLKComplicationTemplateUtilitarianLargeFlat(textProvider: textProvider) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .modularLarge: - let template = CLKComplicationTemplateModularLargeStandardBody() - if #available(watchOSApplicationExtension 6.0, *) { - template.headerTextProvider = CLKTextProvider(format: "Bitcoin Price") - template.body1TextProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueLabel) - template.body2TextProvider = CLKTextProvider(format: "at %@", timeLabel) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .extraLarge: - let template = CLKComplicationTemplateExtraLargeStackText() - if #available(watchOSApplicationExtension 6.0, *) { - template.line1TextProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueLabel) - template.line2TextProvider = CLKTextProvider(format: "at %@", timeLabel) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .graphicRectangular: - let template = CLKComplicationTemplateGraphicRectangularStandardBody() - if #available(watchOSApplicationExtension 6.0, *) { - template.headerTextProvider = CLKTextProvider(format: "Bitcoin Price") - template.body1TextProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueLabel) - template.body2TextProvider = CLKTextProvider(format: "at %@", timeLabel) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - case .graphicExtraLarge: - if #available(watchOSApplicationExtension 7.0, *) { - let template = CLKComplicationTemplateGraphicExtraLargeCircularStackText() - template.line1TextProvider = CLKTextProvider(format: "%@%@", currencySymbol, valueLabel) - template.line1TextProvider = CLKTextProvider(format: "at %@", timeLabel) - entry = CLKComplicationTimelineEntry(date: date, complicationTemplate: template) - handler(entry) - } else { - handler(nil) - } - @unknown default: - fatalError() - } - } - -} - -func getTimelineEntries(for complication: CLKComplication, before date: Date, limit: Int, withHandler handler: @escaping ([CLKComplicationTimelineEntry]?) -> Void) { - // Call the handler with the timeline entries prior to the given date - handler(nil) -} - -func getTimelineEntries(for complication: CLKComplication, after date: Date, limit: Int, withHandler handler: @escaping ([CLKComplicationTimelineEntry]?) -> Void) { - // Call the handler with the timeline entries after to the given date - handler(nil) -} - -// MARK: - Placeholder Templates - -func getLocalizableSampleTemplate(for complication: CLKComplication, withHandler handler: @escaping (CLKComplicationTemplate?) -> Void) { - // This method will be called once per supported complication, and the results will be cached - let line1Text = CLKSimpleTextProvider(text:"46 K") - let line2Text = CLKSimpleTextProvider(text:"$") - let lineTimeText = CLKSimpleTextProvider(text:"3:40 PM") - - switch complication.family { - case .circularSmall: - let template = CLKComplicationTemplateCircularSmallStackText() - template.line1TextProvider = line1Text - template.line2TextProvider = line2Text - handler(template) - case .utilitarianSmallFlat: - let template = CLKComplicationTemplateUtilitarianSmallFlat() - if #available(watchOSApplicationExtension 6.0, *) { - template.textProvider = CLKTextProvider(format: "%@", "$46,134") - } else { - handler(nil) - } - handler(template) - case .utilitarianSmall: - let template = CLKComplicationTemplateUtilitarianSmallRingImage() - template.imageProvider = CLKImageProvider(onePieceImage: UIImage(named: "Complication/Utilitarian")!) - handler(template) - case .graphicCircular: - if #available(watchOSApplicationExtension 6.0, *) { - let template = CLKComplicationTemplateGraphicCircularStackText() - template.line1TextProvider = line1Text - template.line2TextProvider = line2Text - handler(template) - } else { - handler(nil) - } - case .graphicCorner: - let template = CLKComplicationTemplateGraphicCornerStackText() - if #available(watchOSApplicationExtension 6.0, *) { - template.outerTextProvider = CLKTextProvider(format: "46,134") - template.innerTextProvider = CLKTextProvider(format: "$") - } else { - handler(nil) - } - handler(template) - case .modularSmall: - let template = CLKComplicationTemplateModularSmallStackText() - template.line1TextProvider = line1Text - template.line2TextProvider = line2Text - handler(template) - case .utilitarianLarge: - if #available(watchOSApplicationExtension 7.0, *) { - let textProvider = CLKTextProvider(format: "%@%@", "$", "46,000") - let template = CLKComplicationTemplateUtilitarianLargeFlat(textProvider: textProvider) - handler(template) - } else { - handler(nil) - } - case .graphicBezel: - let template = CLKComplicationTemplateGraphicBezelCircularText() - if #available(watchOSApplicationExtension 6.0, *) { - template.textProvider = CLKTextProvider(format: "%@%@", "$S", "46,000") - let imageProvider = CLKFullColorImageProvider(fullColorImage: UIImage(named: "Complication/Graphic Bezel")!) - let circularTemplate = CLKComplicationTemplateGraphicCircularImage() - circularTemplate.imageProvider = imageProvider - template.circularTemplate = circularTemplate - handler(template) - } else { - handler(nil) - } - case .modularLarge: - let template = CLKComplicationTemplateModularLargeStandardBody() - if #available(watchOSApplicationExtension 6.0, *) { - template.headerTextProvider = CLKTextProvider(format: "Bitcoin Price") - template.body1TextProvider = CLKTextProvider(format: "%@%@", "$S", "46,000") - template.body2TextProvider = lineTimeText - handler(template) - } else { - handler(nil) - } - case .extraLarge: - let template = CLKComplicationTemplateExtraLargeStackText() - template.line1TextProvider = line1Text - template.line2TextProvider = lineTimeText - handler(template) - case .graphicRectangular: - let template = CLKComplicationTemplateGraphicRectangularStandardBody() - if #available(watchOSApplicationExtension 6.0, *) { - template.headerTextProvider = CLKTextProvider(format: "Bitcoin Price") - template.body1TextProvider = CLKTextProvider(format: "%@%@", "$S", "46,000") - template.body2TextProvider = CLKTextProvider(format: "%@", Date().description) - handler(template) - } else { - handler(nil) - } - case .graphicExtraLarge: - if #available(watchOSApplicationExtension 7.0, *) { - let template = CLKComplicationTemplateGraphicExtraLargeCircularStackText() - template.line1TextProvider = line1Text - template.line2TextProvider = line2Text - handler(template) - } else { - handler(nil) - } - @unknown default: - fatalError() - } -} diff --git a/ios/BlueWalletWatch Extension/ExtensionDelegate.swift b/ios/BlueWalletWatch Extension/ExtensionDelegate.swift deleted file mode 100644 index b18a4a215f2..00000000000 --- a/ios/BlueWalletWatch Extension/ExtensionDelegate.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// ExtensionDelegate.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/6/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import ClockKit - -class ExtensionDelegate: NSObject, WKExtensionDelegate { - - func applicationDidFinishLaunching() { - // Perform any final initialization of your application. - scheduleNextReload() - ExtensionDelegate.preferredFiatCurrencyChanged() - } - - static func preferredFiatCurrencyChanged() { - let fiatUnitUserDefaults: FiatUnit - if let preferredFiatCurrency = UserDefaults.standard.string(forKey: "preferredFiatCurrency"), let preferredFiatUnit = fiatUnit(currency: preferredFiatCurrency) { - fiatUnitUserDefaults = preferredFiatUnit - } else { - fiatUnitUserDefaults = fiatUnit(currency: "USD")! - } - WidgetAPI.fetchPrice(currency: fiatUnitUserDefaults.endPointKey) { (data, error) in - if let data = data, let encodedData = try? PropertyListEncoder().encode(data) { - UserDefaults.standard.set(encodedData, forKey: MarketData.string) - UserDefaults.standard.synchronize() - let server = CLKComplicationServer.sharedInstance() - - for complication in server.activeComplications ?? [] { - server.reloadTimeline(for: complication) - } - } - } - } - - func nextReloadTime(after date: Date) -> Date { - let calendar = Calendar(identifier: .gregorian) - return calendar.date(byAdding: .minute, value: 10, to: date)! - } - - func scheduleNextReload() { - let targetDate = nextReloadTime(after: Date()) - - NSLog("ExtensionDelegate: scheduling next update at %@", "\(targetDate)") - - WKExtension.shared().scheduleBackgroundRefresh( - withPreferredDate: targetDate, - userInfo: nil, - scheduledCompletion: { _ in } - ) - } - - func reloadActiveComplications() { - let server = CLKComplicationServer.sharedInstance() - - for complication in server.activeComplications ?? [] { - server.reloadTimeline(for: complication) - } - } - - - func applicationDidBecomeActive() { - // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. - } - - func applicationWillResignActive() { - // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. - // Use this method to pause ongoing tasks, disable timers, etc. - } - - func handle(_ backgroundTasks: Set) { - for task in backgroundTasks { - switch task { - case let backgroundTask as WKApplicationRefreshBackgroundTask: - NSLog("ExtensionDelegate: handling WKApplicationRefreshBackgroundTask") - - scheduleNextReload() - let fiatUnitUserDefaults: FiatUnit - if let preferredFiatCurrency = UserDefaults.standard.string(forKey: "preferredFiatCurrency"), let preferredFiatUnit = fiatUnit(currency: preferredFiatCurrency) { - fiatUnitUserDefaults = preferredFiatUnit - } else { - fiatUnitUserDefaults = fiatUnit(currency: "USD")! - } - WidgetAPI.fetchPrice(currency: fiatUnitUserDefaults.endPointKey) { [weak self] (data, error) in - if let data = data, let encodedData = try? PropertyListEncoder().encode(data) { - UserDefaults.standard.set(encodedData, forKey: MarketData.string) - UserDefaults.standard.synchronize() - self?.reloadActiveComplications() - backgroundTask.setTaskCompletedWithSnapshot(false) - } - } - - default: - task.setTaskCompletedWithSnapshot(false) - } - } - } - - - -} diff --git a/ios/BlueWalletWatch Extension/Info.plist b/ios/BlueWalletWatch Extension/Info.plist deleted file mode 100644 index 8953210aecb..00000000000 --- a/ios/BlueWalletWatch Extension/Info.plist +++ /dev/null @@ -1,55 +0,0 @@ - - - - - CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) - CFBundleDisplayName - BlueWalletWatch Extension - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - $(PRODUCT_BUNDLE_IDENTIFIER) - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - $(PRODUCT_NAME) - CFBundlePackageType - XPC! - CFBundleShortVersionString - $(MARKETING_VERSION) - CFBundleVersion - $(CURRENT_PROJECT_VERSION) - CLKComplicationPrincipalClass - $(PRODUCT_MODULE_NAME).ComplicationController - CLKComplicationSupportedFamilies - - CLKComplicationFamilyCircularSmall - CLKComplicationFamilyGraphicBezel - CLKComplicationFamilyGraphicCircular - CLKComplicationFamilyGraphicRectangular - CLKComplicationFamilyGraphicCorner - CLKComplicationFamilyExtraLarge - CLKComplicationFamilyGraphicExtraLarge - CLKComplicationFamilyModularLarge - CLKComplicationFamilyModularSmall - CLKComplicationFamilyUtilitarianLarge - CLKComplicationFamilyUtilitarianSmall - CLKComplicationFamilyUtilitarianSmallFlat - - LSApplicationCategoryType - - NSExtension - - NSExtensionAttributes - - WKAppBundleIdentifier - io.bluewallet.bluewallet.watch - - NSExtensionPointIdentifier - com.apple.watchkit - - WKExtensionDelegateClassName - $(PRODUCT_MODULE_NAME).ExtensionDelegate - - diff --git a/ios/BlueWalletWatch Extension/InterfaceController.swift b/ios/BlueWalletWatch Extension/InterfaceController.swift deleted file mode 100644 index 31d6abb1a33..00000000000 --- a/ios/BlueWalletWatch Extension/InterfaceController.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// InterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/6/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import Foundation - -class InterfaceController: WKInterfaceController { - - @IBOutlet weak var walletsTable: WKInterfaceTable! - @IBOutlet weak var noWalletsAvailableLabel: WKInterfaceLabel! - private let userActivity: NSUserActivity = NSUserActivity(activityType: HandoffIdentifier.ReceiveOnchain.rawValue) - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - update(userActivity) - - userActivity.userInfo = [HandOffUserInfoKey.ReceiveOnchain.rawValue: "bc1q2uvss3v0qh5smluggyqrzjgnqdg5xmun6afwpz"] - userActivity.isEligibleForHandoff = true; - userActivity.becomeCurrent() - if (WatchDataSource.shared.wallets.isEmpty) { - noWalletsAvailableLabel.setHidden(false) - } else { - processWalletsTable() - } - NotificationCenter.default.addObserver(self, selector: #selector(processWalletsTable), name: WatchDataSource.NotificationName.dataUpdated, object: nil) - } - - @objc private func processWalletsTable() { - walletsTable.setNumberOfRows(WatchDataSource.shared.wallets.count, withRowType: WalletInformation.identifier) - - for index in 0.. Any? { - return rowIndex; - } - -} diff --git a/ios/BlueWalletWatch Extension/NotificationController.swift b/ios/BlueWalletWatch Extension/NotificationController.swift deleted file mode 100644 index c9b649e1ca4..00000000000 --- a/ios/BlueWalletWatch Extension/NotificationController.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// NotificationController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/6/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import Foundation -import UserNotifications - - -class NotificationController: WKUserNotificationInterfaceController { - - override init() { - // Initialize variables here. - super.init() - - // Configure interface objects here. - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - } - - override func didDeactivate() { - // This method is called when watch view controller is no longer visible - super.didDeactivate() - } - - override func didReceive(_ notification: UNNotification) { - // This method is called when a notification needs to be presented. - // Implement it if you use a dynamic notification interface. - // Populate your dynamic notification interface as quickly as possible. - } -} diff --git a/ios/BlueWalletWatch Extension/NumericKeypadInterfaceController.swift b/ios/BlueWalletWatch Extension/NumericKeypadInterfaceController.swift deleted file mode 100644 index 1a78fddf3f5..00000000000 --- a/ios/BlueWalletWatch Extension/NumericKeypadInterfaceController.swift +++ /dev/null @@ -1,155 +0,0 @@ -// -// NumericKeypadInterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/23/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import Foundation - - -class NumericKeypadInterfaceController: WKInterfaceController { - - static let identifier = "NumericKeypadInterfaceController" - private var amount: [String] = ["0"] - var keyPadType: NumericKeypadType = .BTC - struct NotificationName { - static let keypadDataChanged = Notification.Name(rawValue: "Notification.NumericKeypadInterfaceController.keypadDataChanged") - } - struct Notifications { - static let keypadDataChanged = Notification(name: NotificationName.keypadDataChanged) - } - enum NumericKeypadType: String { - case BTC = "BTC" - case SATS = "sats" - } - - @IBOutlet weak var periodButton: WKInterfaceButton! - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - if let context = context as? SpecifyInterfaceController.SpecificQRCodeContent { - amount = context.amountStringArray - keyPadType = context.bitcoinUnit - } - periodButton.setEnabled(keyPadType == .SATS) - } - - override func willActivate() { - // This method is called when watch view controller is about to be visible to user - super.willActivate() - updateTitle() - } - - private func updateTitle() { - var title = "" - for amount in self.amount { - let isValid = Double(amount) - if amount == "." || isValid != nil { - title.append(String(amount)) - } - } - if title.isEmpty { - title = "0" - } - setTitle("< \(title) \(keyPadType)") - NotificationCenter.default.post(name: NotificationName.keypadDataChanged, object: amount) - } - - private func append(value: String) { - guard amount.filter({$0 != "."}).count <= 9 && !(amount.contains(".") && value == ".") else { - return - } - switch keyPadType { - case .SATS: - if amount.first == "0" { - if value == "0" { - return - } - amount[0] = value - } else { - amount.append(value) - } - case .BTC: - if amount.isEmpty { - if (value == "0") { - amount.append("0") - } else if value == "." && !amount.contains(".") { - amount.append("0") - amount.append(".") - } else { - amount.append(value) - } - } else if let first = amount.first, first == "0" { - if amount.count > 1, amount[1] != "." { - amount.insert(".", at: 1) - } else if amount.count == 1, amount.first == "0" && value != "." { - amount.append(".") - amount.append(value) - } else { - amount.append(value) - } - } else { - amount.append(value) - } - } - updateTitle() - } - - @IBAction func keypadNumberOneTapped() { - append(value: "1") - } - - @IBAction func keypadNumberTwoTapped() { - append(value: "2") - } - - @IBAction func keypadNumberThreeTapped() { - append(value: "3") - } - - @IBAction func keypadNumberFourTapped() { - append(value: "4") - } - - @IBAction func keypadNumberFiveTapped() { - append(value: "5") - } - - @IBAction func keypadNumberSixTapped() { - append(value: "6") - } - - @IBAction func keypadNumberSevenTapped() { - append(value: "7") - } - - @IBAction func keypadNumberEightTapped() { - append(value: "8") - } - - @IBAction func keypadNumberNineTapped() { - append(value: "9") - } - - @IBAction func keypadNumberZeroTapped() { - append(value: "0") - } - - @IBAction func keypadNumberDotTapped() { - guard !amount.contains("."), keyPadType == .BTC else { return } - append(value: ".") - } - - @IBAction func keypadNumberRemoveTapped() { - guard !amount.isEmpty else { - setTitle("< 0 \(keyPadType)") - return - } - amount.removeLast() - updateTitle() - } - -} diff --git a/ios/BlueWalletWatch Extension/Objects/Handoff.swift b/ios/BlueWalletWatch Extension/Objects/Handoff.swift deleted file mode 100644 index b11ad2b835d..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/Handoff.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// Handoff.swift -// BlueWalletWatch Extension -// -// Created by Admin on 9/27/21. -// Copyright © 2021 BlueWallet. All rights reserved. -// - -import Foundation - -enum HandoffIdentifier: String { - case ReceiveOnchain = "io.bluewallet.bluewallet.receiveonchain" - case Xpub = "io.bluewallet.bluewallet.xpub" - case ViewInBlockExplorer = "io.bluewallet.bluewallet.blockexplorer" -} - -enum HandOffUserInfoKey: String { - case ReceiveOnchain = "address" - case Xpub = "xpub" -} - -enum HandOffTitle: String { - case ReceiveOnchain = "View Address" - case Xpub = "View XPUB" -} diff --git a/ios/BlueWalletWatch Extension/Objects/Transaction.swift b/ios/BlueWalletWatch Extension/Objects/Transaction.swift deleted file mode 100644 index d7fca13af61..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/Transaction.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// Wallet.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/13/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import Foundation - -class Transaction: NSObject, NSCoding { - static let identifier: String = "Transaction" - - let time: String - let memo: String - let amount: String - let type: String - - init(time: String, memo: String, type: String, amount: String) { - self.time = time - self.memo = memo - self.type = type - self.amount = amount - } - - func encode(with aCoder: NSCoder) { - aCoder.encode(time, forKey: "time") - aCoder.encode(memo, forKey: "memo") - aCoder.encode(type, forKey: "type") - aCoder.encode(amount, forKey: "amount") - } - - required init?(coder aDecoder: NSCoder) { - time = aDecoder.decodeObject(forKey: "time") as! String - memo = aDecoder.decodeObject(forKey: "memo") as! String - amount = aDecoder.decodeObject(forKey: "amount") as! String - type = aDecoder.decodeObject(forKey: "type") as! String - } -} diff --git a/ios/BlueWalletWatch Extension/Objects/TransactionTableRow.swift b/ios/BlueWalletWatch Extension/Objects/TransactionTableRow.swift deleted file mode 100644 index ca798901688..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/TransactionTableRow.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// TransactionTableRow.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/10/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit - -class TransactionTableRow: NSObject { - - @IBOutlet private weak var transactionAmountLabel: WKInterfaceLabel! - @IBOutlet private weak var transactionMemoLabel: WKInterfaceLabel! - @IBOutlet private weak var transactionTimeLabel: WKInterfaceLabel! - @IBOutlet private weak var transactionTypeImage: WKInterfaceImage! - - static let identifier: String = "TransactionTableRow" - - var amount: String = "" { - willSet { - transactionAmountLabel.setText(newValue) - } - } - - var memo: String = "" { - willSet { - transactionMemoLabel.setText(newValue) - } - } - - var time: String = "" { - willSet { - transactionTimeLabel.setText(newValue) - } - } - - var type: String = "" { - willSet { - if (newValue == "pendingConfirmation") { - transactionTypeImage.setImage(UIImage(named: "pendingConfirmation")) - } else if (newValue == "received") { - transactionTypeImage.setImage(UIImage(named: "receivedArrow")) - } else if (newValue == "sent") { - transactionTypeImage.setImage(UIImage(named: "sentArrow")) - } else { - transactionTypeImage.setImage(nil) - } - } - } - -} diff --git a/ios/BlueWalletWatch Extension/Objects/Wallet.swift b/ios/BlueWalletWatch Extension/Objects/Wallet.swift deleted file mode 100644 index 6061fc89dd3..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/Wallet.swift +++ /dev/null @@ -1,64 +0,0 @@ -// -// Wallet.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/13/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import Foundation - -enum InterfaceMode { - case Address, QRCode -} - -class Wallet: NSObject, NSCoding { - static let identifier: String = "Wallet" - - var identifier: Int? - let label: String - let balance: String - let type: String - let preferredBalanceUnit: String - let receiveAddress: String - let transactions: [Transaction] - let xpub: String? - let hideBalance: Bool - - init(label: String, balance: String, type: String, preferredBalanceUnit: String, receiveAddress: String, transactions: [Transaction], identifier: Int, xpub: String?, hideBalance: Bool = false) { - self.label = label - self.balance = balance - self.type = type - self.preferredBalanceUnit = preferredBalanceUnit - self.receiveAddress = receiveAddress - self.transactions = transactions - self.identifier = identifier - self.xpub = xpub - self.hideBalance = hideBalance - } - - func encode(with aCoder: NSCoder) { - aCoder.encode(label, forKey: "label") - aCoder.encode(balance, forKey: "balance") - aCoder.encode(type, forKey: "type") - aCoder.encode(receiveAddress, forKey: "receiveAddress") - aCoder.encode(preferredBalanceUnit, forKey: "preferredBalanceUnit") - aCoder.encode(transactions, forKey: "transactions") - aCoder.encode(identifier, forKey: "identifier") - aCoder.encode(xpub, forKey: "xpub") - aCoder.encode(hideBalance, forKey: "hideBalance") - } - - required init?(coder aDecoder: NSCoder) { - label = aDecoder.decodeObject(forKey: "label") as! String - balance = aDecoder.decodeObject(forKey: "balance") as! String - type = aDecoder.decodeObject(forKey: "type") as! String - preferredBalanceUnit = aDecoder.decodeObject(forKey: "preferredBalanceUnit") as! String - receiveAddress = aDecoder.decodeObject(forKey: "receiveAddress") as! String - transactions = aDecoder.decodeObject(forKey: "transactions") as? [Transaction] ?? [Transaction]() - xpub = aDecoder.decodeObject(forKey: "xpub") as? String - hideBalance = aDecoder.decodeObject(forKey: "hideBalance") as? Bool ?? false - - } - -} diff --git a/ios/BlueWalletWatch Extension/Objects/WalletGradient.swift b/ios/BlueWalletWatch Extension/Objects/WalletGradient.swift deleted file mode 100644 index 63b74ecc864..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/WalletGradient.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// WalletGradient.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/23/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import Foundation - -enum WalletGradient: String { - case SegwitHD = "HDsegwitP2SH" - case Segwit = "segwitP2SH" - case LightningCustodial = "lightningCustodianWallet" - case LightningLDK = "lightningLdk" - case SegwitNative = "HDsegwitBech32" - case WatchOnly = "watchOnly" - case MultiSig = "HDmultisig" - - var imageString: String{ - switch self { - case .Segwit: - return "wallet" - case .SegwitNative: - return "walletHDSegwitNative" - case .SegwitHD: - return "walletHD" - case .WatchOnly: - return "walletWatchOnly" - case .LightningCustodial, .LightningLDK: - return "walletLightningCustodial" - case .MultiSig: - return "watchMultisig" - } - } -} diff --git a/ios/BlueWalletWatch Extension/Objects/WalletInformation.swift b/ios/BlueWalletWatch Extension/Objects/WalletInformation.swift deleted file mode 100644 index 47c36953399..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/WalletInformation.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// WalletInformation.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/10/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit - -class WalletInformation: NSObject { - - @IBOutlet weak var walletBalanceLabel: WKInterfaceLabel! - @IBOutlet private weak var walletNameLabel: WKInterfaceLabel! - @IBOutlet private weak var walletGroup: WKInterfaceGroup! - static let identifier: String = "WalletInformation" - - var name: String = "" { - willSet { - walletNameLabel.setText(newValue) - } - } - - var balance: String = "" { - willSet { - walletBalanceLabel.setText(newValue) - } - } - - var type: WalletGradient = .SegwitHD { - willSet { - walletGroup.setBackgroundImageNamed(newValue.imageString) - } - } - -} diff --git a/ios/BlueWalletWatch Extension/Objects/WatchDataSource.swift b/ios/BlueWalletWatch Extension/Objects/WatchDataSource.swift deleted file mode 100644 index 93bd98fd2c5..00000000000 --- a/ios/BlueWalletWatch Extension/Objects/WatchDataSource.swift +++ /dev/null @@ -1,140 +0,0 @@ -// -// WatchDataSource.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/20/19. -// Copyright © 2019 Facebook. All rights reserved. -// - - -import Foundation -import WatchConnectivity - -class WatchDataSource: NSObject, WCSessionDelegate { - struct NotificationName { - static let dataUpdated = Notification.Name(rawValue: "Notification.WalletDataSource.Updated") - } - struct Notifications { - static let dataUpdated = Notification(name: NotificationName.dataUpdated) - } - - static let shared = WatchDataSource() - var wallets: [Wallet] = [Wallet]() - var companionWalletsInitialized = false - private let keychain = KeychainSwift() - - override init() { - super.init() - if let existingData = keychain.getData(Wallet.identifier), let walletData = ((try? NSKeyedUnarchiver.unarchiveTopLevelObjectWithData(existingData) as? [Wallet]) as [Wallet]??) { - guard let walletData = walletData, walletData != self.wallets else { return } - wallets = walletData - WatchDataSource.postDataUpdatedNotification() - } - if WCSession.isSupported() { - print("Activating watch session") - WCSession.default.delegate = self - WCSession.default.activate() - } - } - - func processWalletsData(walletsInfo: [String: Any]) { - if let walletsToProcess = walletsInfo["wallets"] as? [[String: Any]] { - wallets.removeAll(); - for (index, entry) in walletsToProcess.enumerated() { - guard let label = entry["label"] as? String, let balance = entry["balance"] as? String, let type = entry["type"] as? String, let preferredBalanceUnit = entry["preferredBalanceUnit"] as? String, let transactions = entry["transactions"] as? [[String: Any]] else { - continue - } - - var transactionsProcessed = [Transaction]() - for transactionEntry in transactions { - guard let time = transactionEntry["time"] as? String, let memo = transactionEntry["memo"] as? String, let amount = transactionEntry["amount"] as? String, let type = transactionEntry["type"] as? String else { continue } - let transaction = Transaction(time: time, memo: memo, type: type, amount: amount) - transactionsProcessed.append(transaction) - } - let receiveAddress = entry["receiveAddress"] as? String ?? "" - let xpub = entry["xpub"] as? String ?? "" - let hideBalance = entry["hideBalance"] as? Bool ?? false - let wallet = Wallet(label: label, balance: balance, type: type, preferredBalanceUnit: preferredBalanceUnit, receiveAddress: receiveAddress, transactions: transactionsProcessed, identifier: index, xpub: xpub, hideBalance: hideBalance) - wallets.append(wallet) - } - - if let walletsArchived = try? NSKeyedArchiver.archivedData(withRootObject: wallets, requiringSecureCoding: false) { - keychain.set(walletsArchived, forKey: Wallet.identifier) - } - WatchDataSource.postDataUpdatedNotification() - } - } - - static func postDataUpdatedNotification() { - NotificationCenter.default.post(Notifications.dataUpdated) - } - - static func requestLightningInvoice(walletIdentifier: Int, amount: Double, description: String?, responseHandler: @escaping (_ invoice: String) -> Void) { - guard WatchDataSource.shared.wallets.count > walletIdentifier else { - responseHandler("") - return - } - WCSession.default.sendMessage(["request": "createInvoice", "walletIndex": walletIdentifier, "amount": amount, "description": description ?? ""], replyHandler: { (reply: [String : Any]) in - if let invoicePaymentRequest = reply["invoicePaymentRequest"] as? String, !invoicePaymentRequest.isEmpty { - responseHandler(invoicePaymentRequest) - } else { - responseHandler("") - } - }) { (error) in - print(error) - responseHandler("") - - } - } - - static func toggleWalletHideBalance(walletIdentifier: Int, hideBalance: Bool, responseHandler: @escaping (_ invoice: String) -> Void) { - guard WatchDataSource.shared.wallets.count > walletIdentifier else { - responseHandler("") - return - } - WCSession.default.sendMessage(["message": "hideBalance", "walletIndex": walletIdentifier, "hideBalance": hideBalance], replyHandler: { (reply: [String : Any]) in - responseHandler("") - }) { (error) in - print(error) - responseHandler("") - - } - } - - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any]) { - processData(data: applicationContext) - } - - func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String : Any], replyHandler: @escaping ([String : Any]) -> Void) { - processData(data: applicationContext) - } - - func processData(data: [String: Any]) { - if let preferredFiatCurrency = data["preferredFiatCurrency"] as? String, let preferredFiatCurrencyUnit = fiatUnit(currency: preferredFiatCurrency) { - UserDefaults.standard.set(preferredFiatCurrencyUnit.endPointKey, forKey: "preferredFiatCurrency") - UserDefaults.standard.synchronize() - ExtensionDelegate.preferredFiatCurrencyChanged() - } else if let isWalletsInitialized = data["isWalletsInitialized"] as? Bool { - companionWalletsInitialized = isWalletsInitialized - NotificationCenter.default.post(Notifications.dataUpdated) - } else { - WatchDataSource.shared.processWalletsData(walletsInfo: data) - } - } - - func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) { - processData(data: userInfo) - } - - func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) { - if activationState == .activated { - WCSession.default.sendMessage(["message" : "sendApplicationContext"], replyHandler: { (replyData) in - }) { (error) in - print(error) - } - } else { - WatchDataSource.shared.companionWalletsInitialized = false - } - } - -} diff --git a/ios/BlueWalletWatch Extension/PushNotificationPayload.apns b/ios/BlueWalletWatch Extension/PushNotificationPayload.apns deleted file mode 100644 index 5ac55268af8..00000000000 --- a/ios/BlueWalletWatch Extension/PushNotificationPayload.apns +++ /dev/null @@ -1,20 +0,0 @@ -{ - "aps": { - "alert": { - "body": "Test message", - "title": "Optional title", - "subtitle": "Optional subtitle" - }, - "category": "myCategory", - "thread-id":"5280" - }, - - "WatchKit Simulator Actions": [ - { - "title": "First Button", - "identifier": "firstButtonAction" - } - ], - - "customKey": "Use this file to define a testing payload for your notifications. The aps dictionary specifies the category, alert text and title. The WatchKit Simulator Actions array can provide info for one or more action buttons in addition to the standard Dismiss button. Any other top level keys are custom payload. If you have multiple such JSON files in your project, you'll be able to select them when choosing to debug the notification interface of your Watch App." -} diff --git a/ios/BlueWalletWatch Extension/ReceiveInterfaceController.swift b/ios/BlueWalletWatch Extension/ReceiveInterfaceController.swift deleted file mode 100644 index d36a5916fdf..00000000000 --- a/ios/BlueWalletWatch Extension/ReceiveInterfaceController.swift +++ /dev/null @@ -1,192 +0,0 @@ -// -// ReceiveInterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/12/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import Foundation -import EFQRCode - -class ReceiveInterfaceController: WKInterfaceController { - - static let identifier = "ReceiveInterfaceController" - private var wallet: Wallet? { - didSet { - if let address = wallet?.receiveAddress { - userActivity.userInfo = [HandOffUserInfoKey.ReceiveOnchain.rawValue: address] - userActivity.isEligibleForHandoff = true; - userActivity.becomeCurrent() - } - } - } - private var isRenderingQRCode: Bool? - private var receiveMethod: String = "receive" - private var interfaceMode: InterfaceMode = .Address - @IBOutlet weak var addressLabel: WKInterfaceLabel! - @IBOutlet weak var loadingIndicator: WKInterfaceGroup! - @IBOutlet weak var imageInterface: WKInterfaceImage! - private let userActivity: NSUserActivity = NSUserActivity(activityType: HandoffIdentifier.ReceiveOnchain.rawValue) - - override func willActivate() { - super.willActivate() - userActivity.title = HandOffTitle.ReceiveOnchain.rawValue - userActivity.requiredUserInfoKeys = [HandOffUserInfoKey.Xpub.rawValue] - userActivity.isEligibleForHandoff = true - update(userActivity) - } - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - guard let passedContext = context as? (Int, String), WatchDataSource.shared.wallets.count >= passedContext.0 else { - pop() - return - } - let identifier = passedContext.0 - let wallet = WatchDataSource.shared.wallets[identifier] - self.wallet = wallet - receiveMethod = passedContext.1 - NotificationCenter.default.addObserver(forName: SpecifyInterfaceController.NotificationName.createQRCode, object: nil, queue: nil) { [weak self] (notification) in - self?.isRenderingQRCode = true - if let wallet = self?.wallet, wallet.type == WalletGradient.LightningCustodial.rawValue || wallet.type == WalletGradient.LightningLDK.rawValue, self?.receiveMethod == "createInvoice", let object = notification.object as? SpecifyInterfaceController.SpecificQRCodeContent, let amount = object.amount { - self?.imageInterface.setHidden(true) - self?.loadingIndicator.setHidden(false) - WatchDataSource.requestLightningInvoice(walletIdentifier: identifier, amount: amount, description: object.description, responseHandler: { (invoice) in - DispatchQueue.main.async { - if (!invoice.isEmpty) { - guard let cgImage = EFQRCode.generate( - content: "lightning:\(invoice)", inputCorrectionLevel: .h, pointShape: .circle) else { - return - } - let image = UIImage(cgImage: cgImage) - self?.loadingIndicator.setHidden(true) - self?.imageInterface.setHidden(false) - self?.imageInterface.setImage(nil) - self?.imageInterface.setImage(image) - self?.addressLabel.setText(invoice) - self?.interfaceMode = .QRCode - self?.toggleViewButtonPressed() - WCSession.default.sendMessage(["message": "fetchTransactions"], replyHandler: nil, errorHandler: nil) - } else { - self?.presentAlert(withTitle: "Error", message: "Unable to create invoice. Please open BlueWallet on your iPhone and unlock your wallets.", preferredStyle: .alert, actions: [WKAlertAction(title: "OK", style: .default, handler: { [weak self] in - self?.dismiss() - self?.pop() - })]) - } - } - }) - } else { - guard let notificationObject = notification.object as? SpecifyInterfaceController.SpecificQRCodeContent, let walletContext = self?.wallet, !walletContext.receiveAddress.isEmpty, let receiveAddress = self?.wallet?.receiveAddress else { return } - - var address = "bitcoin:\(receiveAddress)" - - var hasAmount = false - if let amount = notificationObject.amount { - address.append("?amount=\(amount)&") - hasAmount = true - } - if let description = notificationObject.description { - if (!hasAmount) { - address.append("?") - } - address.append("label=\(description)") - } - - DispatchQueue.main.async { - guard let cgImage = EFQRCode.generate( - content: address) else { - return - } - let image = UIImage(cgImage: cgImage) - self?.imageInterface.setImage(nil) - self?.imageInterface.setImage(image) - self?.imageInterface.setHidden(false) - self?.addressLabel.setText(receiveAddress) - self?.interfaceMode = .QRCode - self?.toggleViewButtonPressed() - self?.loadingIndicator.setHidden(true) - self?.isRenderingQRCode = false - } - } - } - - guard !wallet.receiveAddress.isEmpty, let cgImage = EFQRCode.generate( - content: wallet.receiveAddress), receiveMethod != "createInvoice" else { - return - } - - let image = UIImage(cgImage: cgImage) - imageInterface.setImage(image) - - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "textformat.subscript") { - addMenuItem(with: image, title: "Address", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - addressLabel.setText(wallet.receiveAddress) - } - - override func didAppear() { - super.didAppear() - if (wallet?.type == WalletGradient.LightningCustodial.rawValue || wallet?.type == WalletGradient.LightningLDK.rawValue) && receiveMethod == "createInvoice" { - if isRenderingQRCode == nil { - presentController(withName: SpecifyInterfaceController.identifier, context: wallet?.identifier) - isRenderingQRCode = false - } else if isRenderingQRCode == false { - pop() - } - } - } - - override func didDeactivate() { - super.didDeactivate() - NotificationCenter.default.removeObserver(self, name: SpecifyInterfaceController.NotificationName.createQRCode, object: nil) - userActivity.invalidate() - invalidateUserActivity() - - } - - @IBAction func specifyMenuItemTapped() { - presentController(withName: SpecifyInterfaceController.identifier, context: wallet?.identifier) - } - - - @IBAction @objc func toggleViewButtonPressed() { - clearAllMenuItems() - switch interfaceMode { - case .Address: - addressLabel.setHidden(false) - imageInterface.setHidden(true) - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "qrcode") { - addMenuItem(with: image, title: "QR Code", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "QR Code", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "QR Code", action: #selector(toggleViewButtonPressed)) - - } - case .QRCode: - addressLabel.setHidden(true) - imageInterface.setHidden(false) - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "textformat.subscript") { - addMenuItem(with: image, title: "Address", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } - interfaceMode = interfaceMode == .QRCode ? .Address : .QRCode - } -} diff --git a/ios/BlueWalletWatch Extension/SpecifyInterfaceController.swift b/ios/BlueWalletWatch Extension/SpecifyInterfaceController.swift deleted file mode 100644 index cf13fbea970..00000000000 --- a/ios/BlueWalletWatch Extension/SpecifyInterfaceController.swift +++ /dev/null @@ -1,109 +0,0 @@ -// -// SpecifyInterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/23/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import WatchConnectivity -import Foundation - -class SpecifyInterfaceController: WKInterfaceController { - - static let identifier = "SpecifyInterfaceController" - @IBOutlet weak var descriptionButton: WKInterfaceButton! - @IBOutlet weak var amountButton: WKInterfaceButton! - @IBOutlet weak var createButton: WKInterfaceButton! - - struct SpecificQRCodeContent { - var amount: Double? - var description: String? - var amountStringArray: [String] = ["0"] - var bitcoinUnit: NumericKeypadInterfaceController.NumericKeypadType = .BTC - } - var specifiedQRContent: SpecificQRCodeContent = SpecificQRCodeContent(amount: nil, description: nil, amountStringArray: ["0"], bitcoinUnit: .BTC) - var wallet: Wallet? - struct NotificationName { - static let createQRCode = Notification.Name(rawValue: "Notification.SpecifyInterfaceController.createQRCode") - } - struct Notifications { - static let createQRCode = Notification(name: NotificationName.createQRCode) - } - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - guard let identifier = context as? Int, WatchDataSource.shared.wallets.count > identifier else { - return - } - let wallet = WatchDataSource.shared.wallets[identifier] - self.wallet = wallet - self.createButton.setAlpha(0.5) - self.specifiedQRContent.bitcoinUnit = (wallet.type == WalletGradient.LightningCustodial.rawValue || wallet.type == WalletGradient.LightningLDK.rawValue) ? .SATS : .BTC - NotificationCenter.default.addObserver(forName: NumericKeypadInterfaceController.NotificationName.keypadDataChanged, object: nil, queue: nil) { [weak self] (notification) in - guard let amountObject = notification.object as? [String], !amountObject.isEmpty else { return } - if amountObject.count == 1 && (amountObject.first == "." || amountObject.first == "0") { - return - } - var title = "" - for amount in amountObject { - let isValid = Double(amount) - if amount == "." || isValid != nil { - title.append(String(amount)) - } - } - self?.specifiedQRContent.amountStringArray = amountObject - if let amountDouble = Double(title), let keyPadType = self?.specifiedQRContent.bitcoinUnit { - self?.specifiedQRContent.amount = amountDouble - self?.amountButton.setTitle("\(title) \(keyPadType)") - - var isShouldCreateButtonBeEnabled = amountDouble > 0 && !title.isEmpty - - if (wallet.type == WalletGradient.LightningCustodial.rawValue || wallet.type == WalletGradient.LightningLDK.rawValue) && !WCSession.default.isReachable { - isShouldCreateButtonBeEnabled = false - } - - self?.createButton.setEnabled(isShouldCreateButtonBeEnabled) - self?.createButton.setAlpha(isShouldCreateButtonBeEnabled ? 1.0 : 0.5) - } - } - } - - override func didDeactivate() { - // This method is called when watch view controller is no longer visible - super.didDeactivate() - NotificationCenter.default.removeObserver(self, name: NumericKeypadInterfaceController.NotificationName.keypadDataChanged, object: nil) - } - - @IBAction func descriptionButtonTapped() { - presentTextInputController(withSuggestions: nil, allowedInputMode: .allowEmoji) { [weak self] (result: [Any]?) in - DispatchQueue.main.async { - if let result = result, let text = result.first as? String { - self?.specifiedQRContent.description = text - self?.descriptionButton.setTitle(nil) - self?.descriptionButton.setTitle(text) - } - } - } - } - - @IBAction func createButtonTapped() { - if WatchDataSource.shared.companionWalletsInitialized { - NotificationCenter.default.post(name: NotificationName.createQRCode, object: specifiedQRContent) - dismiss() - } else { - presentAlert(withTitle: "Error", message: "Unable to create invoice. Please open BlueWallet on your iPhone and unlock your wallets.", preferredStyle: .alert, actions: [WKAlertAction(title: "OK", style: .default, handler: { [weak self] in - self?.dismiss() - })]) - } - } - - override func contextForSegue(withIdentifier segueIdentifier: String) -> Any? { - if segueIdentifier == NumericKeypadInterfaceController.identifier { - return specifiedQRContent - } - return nil - } - -} diff --git a/ios/BlueWalletWatch Extension/ViewQRCodefaceController.swift b/ios/BlueWalletWatch Extension/ViewQRCodefaceController.swift deleted file mode 100644 index b2b6c8b4869..00000000000 --- a/ios/BlueWalletWatch Extension/ViewQRCodefaceController.swift +++ /dev/null @@ -1,105 +0,0 @@ -// -// ReceiveInterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/12/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import Foundation -import EFQRCode - -class ViewQRCodefaceController: WKInterfaceController { - - static let identifier = "ViewQRCodefaceController" - @IBOutlet weak var imageInterface: WKInterfaceImage! - @IBOutlet weak var addressLabel: WKInterfaceLabel! - var address: String? { - didSet { - if let address = address, !address.isEmpty{ - userActivity.userInfo = [HandOffUserInfoKey.Xpub.rawValue: address] - userActivity.becomeCurrent() - } - } - } - private var interfaceMode = InterfaceMode.Address - private let userActivity: NSUserActivity = NSUserActivity(activityType: HandoffIdentifier.Xpub.rawValue) - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - userActivity.title = HandOffTitle.Xpub.rawValue - userActivity.requiredUserInfoKeys = [HandOffUserInfoKey.Xpub.rawValue] - userActivity.isEligibleForHandoff = true - guard let passedContext = context as? String else { - pop() - return - } - address = passedContext - addressLabel.setText(passedContext) - - DispatchQueue.main.async { - guard let cgImage = EFQRCode.generate( - content: passedContext) else { - return - } - let image = UIImage(cgImage: cgImage) - self.imageInterface.setImage(nil) - self.imageInterface.setImage(image) - } - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "textformat.subscript") { - addMenuItem(with: image, title: "Address", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } - @IBAction @objc func toggleViewButtonPressed() { - clearAllMenuItems() - switch interfaceMode { - case .Address: - addressLabel.setHidden(false) - imageInterface.setHidden(true) - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "qrcode") { - addMenuItem(with: image, title: "QR Code", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "QR Code", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "QR Code", action: #selector(toggleViewButtonPressed)) - - } - case .QRCode: - addressLabel.setHidden(true) - imageInterface.setHidden(false) - if #available(watchOSApplicationExtension 6.0, *) { - if let image = UIImage(systemName: "textformat.subscript") { - addMenuItem(with: image, title: "Address", action:#selector(toggleViewButtonPressed)) - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } else { - addMenuItem(with: .shuffle, title: "Address", action: #selector(toggleViewButtonPressed)) - } - } - interfaceMode = interfaceMode == .QRCode ? .Address : .QRCode - } - - override func willActivate() { - super.willActivate() - update(userActivity) - } - - - override func didDeactivate() { - super.didDeactivate() - userActivity.invalidate() - invalidateUserActivity() - } - - -} diff --git a/ios/BlueWalletWatch Extension/WalletDetailsInterfaceController.swift b/ios/BlueWalletWatch Extension/WalletDetailsInterfaceController.swift deleted file mode 100644 index 711caeeb1dd..00000000000 --- a/ios/BlueWalletWatch Extension/WalletDetailsInterfaceController.swift +++ /dev/null @@ -1,130 +0,0 @@ -// -// WalletDetailsInterfaceController.swift -// BlueWalletWatch Extension -// -// Created by Marcos Rodriguez on 3/11/19. -// Copyright © 2019 Facebook. All rights reserved. -// - -import WatchKit -import Foundation -import WatchConnectivity - -class WalletDetailsInterfaceController: WKInterfaceController { - - var wallet: Wallet? - static let identifier = "WalletDetailsInterfaceController" - @IBOutlet weak var walletBasicsGroup: WKInterfaceGroup! - @IBOutlet weak var walletBalanceLabel: WKInterfaceLabel! - @IBOutlet weak var createInvoiceButton: WKInterfaceButton! - @IBOutlet weak var walletNameLabel: WKInterfaceLabel! - @IBOutlet weak var receiveButton: WKInterfaceButton! - @IBOutlet weak var viewXPubButton: WKInterfaceButton! - @IBOutlet weak var noTransactionsLabel: WKInterfaceLabel! - @IBOutlet weak var transactionsTable: WKInterfaceTable! - - - override func awake(withContext context: Any?) { - super.awake(withContext: context) - guard let identifier = context as? Int else { - pop() - return - } - processInterface(identifier: identifier) - } - - func processInterface(identifier: Int) { - let wallet = WatchDataSource.shared.wallets[identifier] - self.wallet = wallet - walletBalanceLabel.setHidden(wallet.hideBalance) - walletBalanceLabel.setText(wallet.hideBalance ? "" : wallet.balance) - walletNameLabel.setText(wallet.label) - walletBasicsGroup.setBackgroundImageNamed(WalletGradient(rawValue: wallet.type)?.imageString) - createInvoiceButton.setHidden(!(wallet.type == WalletGradient.LightningCustodial.rawValue || wallet.type == WalletGradient.LightningLDK.rawValue)) - receiveButton.setHidden(wallet.receiveAddress.isEmpty) - viewXPubButton.setHidden(!((wallet.type != WalletGradient.LightningCustodial.rawValue || wallet.type != WalletGradient.LightningLDK.rawValue) && !(wallet.xpub ?? "").isEmpty)) - processWalletsTable() - } - - - @IBAction func toggleBalanceVisibility(_ sender: Any) { - guard let wallet = wallet else { - return - } - - if wallet.hideBalance { - showBalanceMenuItemTapped() - } else{ - hideBalanceMenuItemTapped() - } - } - - - @objc func showBalanceMenuItemTapped() { - guard let identifier = wallet?.identifier else { return } - WatchDataSource.toggleWalletHideBalance(walletIdentifier: identifier, hideBalance: false) { [weak self] _ in - DispatchQueue.main.async { - WatchDataSource.postDataUpdatedNotification() - self?.processInterface(identifier: identifier) - } - } - } - - @objc func hideBalanceMenuItemTapped() { - guard let identifier = wallet?.identifier else { return } - WatchDataSource.toggleWalletHideBalance(walletIdentifier: identifier, hideBalance: true) { [weak self] _ in - DispatchQueue.main.async { - WatchDataSource.postDataUpdatedNotification() - self?.processInterface(identifier: identifier) - } - } - } - - @IBAction func viewXPubMenuItemTapped() { - guard let xpub = wallet?.xpub else { - return - } - presentController(withName: ViewQRCodefaceController.identifier, context: xpub) - } - - override func willActivate() { - super.willActivate() - transactionsTable.setHidden(wallet?.transactions.isEmpty ?? true) - noTransactionsLabel.setHidden(!(wallet?.transactions.isEmpty ?? false)) - } - - @IBAction func receiveMenuItemTapped() { - presentController(withName: ReceiveInterfaceController.identifier, context: (wallet, "receive")) - } - - - @objc private func processWalletsTable() { - transactionsTable.setNumberOfRows(wallet?.transactions.count ?? 0, withRowType: TransactionTableRow.identifier) - - for index in 0.. Any? { - return (wallet?.identifier, "receive") - } - -} diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/58.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/58.png deleted file mode 100644 index daedd5ceea9..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/58.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/87.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/87.png deleted file mode 100644 index 1689795ed48..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/87.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 4bc91346428..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,92 +0,0 @@ -{ - "images" : [ - { - "size" : "24x24", - "idiom" : "watch", - "filename" : "Icon-48.png", - "scale" : "2x", - "role" : "notificationCenter", - "subtype" : "38mm" - }, - { - "size" : "27.5x27.5", - "idiom" : "watch", - "filename" : "Icon-55.png", - "scale" : "2x", - "role" : "notificationCenter", - "subtype" : "42mm" - }, - { - "size" : "29x29", - "idiom" : "watch", - "filename" : "58.png", - "role" : "companionSettings", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "watch", - "filename" : "87.png", - "role" : "companionSettings", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "watch", - "filename" : "watch.png", - "scale" : "2x", - "role" : "appLauncher", - "subtype" : "38mm" - }, - { - "size" : "44x44", - "idiom" : "watch", - "filename" : "Icon-88.png", - "scale" : "2x", - "role" : "appLauncher", - "subtype" : "40mm" - }, - { - "size" : "50x50", - "idiom" : "watch", - "filename" : "Icon-173.png", - "scale" : "2x", - "role" : "appLauncher", - "subtype" : "44mm" - }, - { - "size" : "86x86", - "idiom" : "watch", - "filename" : "Icon-172.png", - "scale" : "2x", - "role" : "quickLook", - "subtype" : "38mm" - }, - { - "size" : "98x98", - "idiom" : "watch", - "filename" : "Icon-196.png", - "scale" : "2x", - "role" : "quickLook", - "subtype" : "42mm" - }, - { - "size" : "108x108", - "idiom" : "watch", - "filename" : "group-copy-2@3x.png", - "scale" : "2x", - "role" : "quickLook", - "subtype" : "44mm" - }, - { - "size" : "1024x1024", - "idiom" : "watch-marketing", - "filename" : "1024.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-172.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-172.png deleted file mode 100644 index 3b0c5fc72fa..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-172.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-173.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-173.png deleted file mode 100644 index 7d500f2450c..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-173.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-196.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-196.png deleted file mode 100644 index fae20c7e843..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-196.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-48.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-48.png deleted file mode 100644 index 8aad31886af..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-48.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-55.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-55.png deleted file mode 100644 index fb277c8cd9f..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-55.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-88.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-88.png deleted file mode 100644 index 57e72637232..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/Icon-88.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/group-copy-2@3x.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/group-copy-2@3x.png deleted file mode 100644 index e09536ad6f3..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/group-copy-2@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/watch.png b/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/watch.png deleted file mode 100644 index 996e08eda44..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/AppIcon.appiconset/watch.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json deleted file mode 100644 index ca2af8cca5b..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/Contents.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "screen-width" : "<=145", - "filename" : "circular38mm@2x.png", - "scale" : "2x" - }, - { - "screen-width" : ">161", - "scale" : "2x", - "idiom" : "watch", - "filename" : "circular40mm@2x.png" - }, - { - "scale" : "2x", - "idiom" : "watch", - "filename" : "circular42mm@2x.png", - "screen-width" : ">145" - }, - { - "filename" : "circular44mm@2x.png", - "scale" : "2x", - "idiom" : "watch", - "screen-width" : ">183" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular38mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular38mm@2x.png deleted file mode 100644 index bb02df600c9..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular38mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular40mm@2x.png deleted file mode 100644 index 48c33e2ce19..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular42mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular42mm@2x.png deleted file mode 100644 index 48c33e2ce19..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular42mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular44mm@2x.png deleted file mode 100644 index 8613f833132..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Circular.imageset/circular44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Contents.json deleted file mode 100644 index e8b3252e305..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Contents.json +++ /dev/null @@ -1,53 +0,0 @@ -{ - "assets" : [ - { - "filename" : "Circular.imageset", - "idiom" : "watch", - "role" : "circular" - }, - { - "filename" : "Extra Large.imageset", - "idiom" : "watch", - "role" : "extra-large" - }, - { - "filename" : "Graphic Bezel.imageset", - "idiom" : "watch", - "role" : "graphic-bezel" - }, - { - "filename" : "Graphic Circular.imageset", - "idiom" : "watch", - "role" : "graphic-circular" - }, - { - "filename" : "Graphic Corner.imageset", - "idiom" : "watch", - "role" : "graphic-corner" - }, - { - "filename" : "Graphic Extra Large.imageset", - "idiom" : "watch", - "role" : "graphic-extra-large" - }, - { - "filename" : "Graphic Large Rectangular.imageset", - "idiom" : "watch", - "role" : "graphic-large-rectangular" - }, - { - "filename" : "Modular.imageset", - "idiom" : "watch", - "role" : "modular" - }, - { - "filename" : "Utilitarian.imageset", - "idiom" : "watch", - "role" : "utilitarian" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json deleted file mode 100644 index 762fbec1aa5..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/Contents.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "images" : [ - { - "filename" : "extra-large38mm@2x.png", - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "filename" : "extra-large40mm@2x.png", - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">161" - }, - { - "filename" : "extra-large42mm@2x.png", - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "filename" : "extra-large44mm@2x.png", - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">183" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - }, - "properties" : { - "auto-scaling" : "auto", - "template-rendering-intent" : "template" - } -} diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large38mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large38mm@2x.png deleted file mode 100644 index a20b089b31e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large38mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large40mm@2x.png deleted file mode 100644 index 8265dff09c3..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large42mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large42mm@2x.png deleted file mode 100644 index 8265dff09c3..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large42mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large44mm@2x.png deleted file mode 100644 index 1250239527e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Extra Large.imageset/extra-large44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json deleted file mode 100644 index 5ae95868702..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/Contents.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "idiom" : "watch", - "filename" : "graphic-bezel40mm@2x.png", - "screen-width" : ">161", - "scale" : "2x" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "filename" : "graphic-bezel44mm@2x.png", - "screen-width" : ">183", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel40mm@2x.png deleted file mode 100644 index 83d73e88abb..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel44mm@2x.png deleted file mode 100644 index 26779ade189..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Bezel.imageset/graphic-bezel44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json deleted file mode 100644 index eeb81cb27d7..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/Contents.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "idiom" : "watch", - "filename" : "graphic-circular40mm@2x.png", - "screen-width" : ">161", - "scale" : "2x" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "filename" : "graphic-circular44mm@2x.png", - "screen-width" : ">183", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular40mm@2x.png deleted file mode 100644 index 83d73e88abb..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular44mm@2x.png deleted file mode 100644 index 26779ade189..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Circular.imageset/graphic-circular44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json deleted file mode 100644 index af8a3953bbf..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/Contents.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "idiom" : "watch", - "filename" : "graphic-corner40mm@2x.png", - "screen-width" : ">161", - "scale" : "2x" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "filename" : "graphic-corner44mm@2x.png", - "screen-width" : ">183", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner40mm@2x.png deleted file mode 100644 index 8613f833132..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner44mm@2x.png deleted file mode 100644 index 24743f07c2e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Corner.imageset/graphic-corner44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json deleted file mode 100644 index ed7de25e571..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Extra Large.imageset/Contents.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">161" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">183" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json deleted file mode 100644 index aefef2914e2..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Graphic Large Rectangular.imageset/Contents.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : "<=145" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">161" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">183" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json deleted file mode 100644 index d419e61e820..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/Contents.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "images" : [ - { - "screen-width" : "<=145", - "scale" : "2x", - "idiom" : "watch", - "filename" : "modular38mm@2x.png" - }, - { - "screen-width" : ">161", - "scale" : "2x", - "filename" : "modular40mm@2x.png", - "idiom" : "watch" - }, - { - "scale" : "2x", - "idiom" : "watch", - "filename" : "modular42mm@2x.png", - "screen-width" : ">145" - }, - { - "filename" : "modular44mm@2x.png", - "screen-width" : ">183", - "idiom" : "watch", - "scale" : "2x" - } - ] -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular38mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular38mm@2x.png deleted file mode 100644 index e269254c282..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular38mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular40mm@2x.png deleted file mode 100644 index c9179c3002d..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular42mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular42mm@2x.png deleted file mode 100644 index c9179c3002d..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular42mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular44mm@2x.png deleted file mode 100644 index 839e09ca1c7..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Modular.imageset/modular44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json deleted file mode 100644 index 57102e7efa5..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/Contents.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - }, - "images" : [ - { - "scale" : "2x", - "filename" : "utility38mm@2x.png", - "screen-width" : "<=145", - "idiom" : "watch" - }, - { - "idiom" : "watch", - "scale" : "2x", - "screen-width" : ">161", - "filename" : "utility40mm@2x.png" - }, - { - "scale" : "2x", - "idiom" : "watch", - "filename" : "utility42mm@2x.png", - "screen-width" : ">145" - }, - { - "idiom" : "watch", - "screen-width" : ">183", - "filename" : "utility44mm@2x.png", - "scale" : "2x" - } - ] -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility38mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility38mm@2x.png deleted file mode 100644 index 8613f833132..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility38mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility40mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility40mm@2x.png deleted file mode 100644 index 24743f07c2e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility40mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility42mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility42mm@2x.png deleted file mode 100644 index 24743f07c2e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility42mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility44mm@2x.png b/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility44mm@2x.png deleted file mode 100644 index 15e843a61dd..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/Complication.complicationset/Utilitarian.imageset/utility44mm@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/Contents.json deleted file mode 100644 index da4a164c918..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/Contents.json deleted file mode 100644 index 9bf9642ba9d..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "group-copy-2@3x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/group-copy-2@3x.png b/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/group-copy-2@3x.png deleted file mode 100644 index 6fd7d4f9888..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/loadingIndicator.imageset/group-copy-2@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/Contents.json deleted file mode 100644 index 44952fef452..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "shape@3x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/shape@3x.png b/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/shape@3x.png deleted file mode 100644 index 331ce40da89..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/pendingConfirmation.imageset/shape@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/Contents.json deleted file mode 100644 index d0bec2ab81b..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "qr-code@3x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/qr-code@3x.png b/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/qr-code@3x.png deleted file mode 100644 index b55d46bd7df..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/qr-code.imageset/qr-code@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/Contents.json deleted file mode 100644 index 873216a5744..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "path-copy-3@2x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/path-copy-3@2x.png b/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/path-copy-3@2x.png deleted file mode 100644 index c7bc367c4d1..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/receivedArrow.imageset/path-copy-3@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/Contents.json deleted file mode 100644 index f7c919b94ef..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "path-copy@2x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/path-copy@2x.png b/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/path-copy@2x.png deleted file mode 100644 index 1c8f0424efc..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/sentArrow.imageset/path-copy@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/Contents.json deleted file mode 100644 index f392699966b..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "mask.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "mask@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "mask@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask.png b/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask.png deleted file mode 100644 index e3ee0e7af66..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@2x.png b/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@2x.png deleted file mode 100644 index 3a50d0f53cd..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@3x.png b/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@3x.png deleted file mode 100644 index 08c8699a388..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/wallet.imageset/mask@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/Contents.json deleted file mode 100644 index 5ebd7347fcc..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "mask@3x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/mask@3x.png b/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/mask@3x.png deleted file mode 100644 index 49a027f79d3..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletHD.imageset/mask@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/Contents.json deleted file mode 100644 index f392699966b..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "mask.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "mask@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "mask@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask.png b/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask.png deleted file mode 100644 index 0433aa41866..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@2x.png b/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@2x.png deleted file mode 100644 index 3507ef171dd..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@3x.png b/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@3x.png deleted file mode 100644 index 0101ff35b0e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletHDSegwitNative.imageset/mask@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/Contents.json deleted file mode 100644 index 5ebd7347fcc..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/Contents.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "images" : [ - { - "idiom" : "watch", - "filename" : "mask@3x.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/mask@3x.png b/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/mask@3x.png deleted file mode 100644 index 6a68178f452..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletLightningCustodial.imageset/mask@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/Contents.json deleted file mode 100644 index f392699966b..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "filename" : "mask.png", - "scale" : "1x" - }, - { - "idiom" : "universal", - "filename" : "mask@2x.png", - "scale" : "2x" - }, - { - "idiom" : "universal", - "filename" : "mask@3x.png", - "scale" : "3x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} \ No newline at end of file diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask.png b/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask.png deleted file mode 100644 index 02589174e92..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@2x.png b/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@2x.png deleted file mode 100644 index e6e7199b290..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@3x.png b/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@3x.png deleted file mode 100644 index 819609051f6..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/walletWatchOnly.imageset/mask@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/Contents.json b/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/Contents.json deleted file mode 100644 index e5e09031394..00000000000 --- a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/Contents.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "images" : [ - { - "filename" : "multisig-watch.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "multisig-watch@2x.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "multisig-watch@3x.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch.png b/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch.png deleted file mode 100644 index 23c7457c921..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@2x.png b/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@2x.png deleted file mode 100644 index 051f949f67e..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@2x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@3x.png b/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@3x.png deleted file mode 100644 index e93c48a86e1..00000000000 Binary files a/ios/BlueWalletWatch/Assets.xcassets/watchMultisig.imageset/multisig-watch@3x.png and /dev/null differ diff --git a/ios/BlueWalletWatch/Base.lproj/Interface.storyboard b/ios/BlueWalletWatch/Base.lproj/Interface.storyboard deleted file mode 100644 index 2fbb377a420..00000000000 --- a/ios/BlueWalletWatch/Base.lproj/Interface.storyboard +++ /dev/null @@ -1,382 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - - - -
-
- -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -