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
[](https://github.com/BlueWallet/BlueWallet)
-[](https://circleci.com/gh/BlueWallet/BlueWallet)
[](https://github.com/prettier/prettier)

@@ -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